0.1.26
Loading...
Searching...
No Matches
RadiationModel.py
Go to the documentation of this file.
1"""
2High-level RadiationModel interface for PyHelios.
3
4This module provides a user-friendly interface to the radiation modeling
5capabilities with graceful plugin handling and informative error messages.
6"""
7
8import logging
9import math
10import tempfile
11from typing import List, Optional
12from contextlib import contextmanager
13from pathlib import Path
14import os
15
16import numpy as np
17
18from .plugins.registry import get_plugin_registry, require_plugin, graceful_plugin_fallback
19from .wrappers import URadiationModelWrapper as radiation_wrapper
20from .validation.plugins import (
21 validate_wavelength_range, validate_flux_value, validate_ray_count,
22 validate_direction_vector, validate_band_label, validate_source_id, validate_source_id_list,
23 validate_position_like, validate_direction_like, validate_size_like
24)
25from .validation.plugin_decorators import (
26 validate_radiation_band_params, validate_collimated_source_params, validate_sphere_source_params,
27 validate_sun_sphere_params, validate_get_source_flux_params,
28 validate_update_geometry_params, validate_run_band_params, validate_scattering_depth_params,
29 validate_min_scatter_energy_params
30)
31from .Context import Context, check_context_alive
32from .assets import get_asset_manager
33
34logger = logging.getLogger(__name__)
35
36
37@contextmanager
39 """
40 Context manager that temporarily changes working directory to where RadiationModel assets are located.
41
42 RadiationModel C++ code uses hardcoded relative paths like "plugins/radiation/" for GPU
43 backend files (SPIR-V shaders for Vulkan, PTX files for OptiX), expecting assets relative
44 to working directory. This manager temporarily changes to the build directory where assets
45 are actually located.
46
47 Raises:
48 RuntimeError: If build directory or RadiationModel assets are not found, indicating a build system error.
49 """
50 # Find the build directory containing RadiationModel assets
51 # Try asset manager first (works for both development and wheel installations)
52 asset_manager = get_asset_manager()
53 working_dir = asset_manager._get_helios_build_path()
54
55 if working_dir and working_dir.exists():
56 radiation_assets = working_dir / 'plugins' / 'radiation'
57 else:
58 # For wheel installations, check packaged assets
59 current_dir = Path(__file__).parent
60 packaged_build = current_dir / 'assets' / 'build'
61
62 if packaged_build.exists():
63 working_dir = packaged_build
64 radiation_assets = working_dir / 'plugins' / 'radiation'
65 else:
66 # Fallback to development paths
67 repo_root = current_dir.parent
68 build_lib_dir = repo_root / 'pyhelios_build' / 'build' / 'lib'
69 working_dir = build_lib_dir.parent
70 radiation_assets = working_dir / 'plugins' / 'radiation'
71
72 if not build_lib_dir.exists():
73 raise RuntimeError(
74 f"PyHelios build directory not found at {build_lib_dir}. "
75 f"Run: python build_scripts/build_helios.py --plugins radiation"
76 )
77
78 if not radiation_assets.exists():
79 raise RuntimeError(
80 f"RadiationModel assets not found at {radiation_assets}. "
81 f"This indicates a build system error. The build script should copy shader/backend files to this location."
82 )
83
84 # Change to the build directory temporarily
85 original_dir = os.getcwd()
86 try:
87 os.chdir(working_dir)
88 logger.debug(f"Changed working directory to {working_dir} for RadiationModel asset access")
89 yield working_dir
90 finally:
91 os.chdir(original_dir)
92 logger.debug(f"Restored working directory to {original_dir}")
93
94
95class RadiationModelError(Exception):
96 """Raised when RadiationModel operations fail."""
97 pass
99
100class CameraProperties:
101 """
102 Camera properties for radiation model cameras.
103
104 This class encapsulates the properties needed to configure a radiation camera,
105 providing sensible defaults and validation for camera parameters. Updated for
106 Helios v1.3.60 with camera_zoom support.
107 """
108
109 def __init__(self, camera_resolution=None, focal_plane_distance=1.0, lens_diameter=0.05,
110 HFOV=20.0, FOV_aspect_ratio=0.0, lens_focal_length=0.05,
111 sensor_width_mm=35.0, manufacturer="", model="generic", lens_make="",
112 lens_model="", lens_specification="", exposure="auto", shutter_speed=1.0/125.0,
113 white_balance="auto", camera_zoom=1.0):
114 """
115 Initialize camera properties with defaults matching C++ CameraProperties.
116
117 Args:
118 camera_resolution: Camera resolution as (width, height) tuple or list. Default: (512, 512)
119 focal_plane_distance: Distance from viewing plane to focal plane (working distance). Default: 1.0
120 lens_diameter: Diameter of camera lens (0 = pinhole camera). Default: 0.05
121 HFOV: Horizontal field of view in degrees. Default: 20.0
122 FOV_aspect_ratio: Ratio of horizontal to vertical FOV. Default: 0.0 (auto-calculate from resolution)
123 lens_focal_length: Camera lens optical focal length in meters (physical, not 35mm equiv). Default: 0.05 (50mm)
124 sensor_width_mm: Physical sensor width in mm. Default: 35.0 (full-frame)
125 manufacturer: Camera manufacturer (e.g., "Canon", "Nikon", "Apple"). In helios-core
126 v1.3.73 this maps to the EXIF Make tag (empty ⇒ "Helios"). NOTE: like the other
127 string fields (model, lens_make, etc.), this attribute is not yet plumbed through to
128 the native camera and currently has no effect on written images; the C++ default is
129 used. It is exposed for forward compatibility. Default: ""
130 model: Camera model name (e.g., "Nikon D700", "Canon EOS 5D"). Default: "generic"
131 lens_make: Lens manufacturer (e.g., "Canon", "Nikon"). Default: ""
132 lens_model: Lens model name (e.g., "AF-S NIKKOR 50mm f/1.8G"). Default: ""
133 lens_specification: Lens specification (e.g., "50mm f/1.8"). Default: ""
134 exposure: Exposure mode - "auto", "ISOXXX" (e.g., "ISO100"), or "manual". Default: "auto"
135 shutter_speed: Camera shutter speed in seconds (e.g., 0.008 for 1/125s). Default: 0.008 (1/125s)
136 white_balance: White balance mode - "auto" or "off". Default: "auto"
137 camera_zoom: Camera optical zoom multiplier. 1.0 = no zoom, 2.0 = 2x zoom.
138 Scales effective HFOV: effective_HFOV = HFOV / camera_zoom. Default: 1.0
139 """
140 # Set camera resolution with validation
141
142 if camera_resolution is None:
143 self.camera_resolution = (512, 512)
144 else:
145 if isinstance(camera_resolution, (list, tuple)) and len(camera_resolution) == 2:
146 self.camera_resolution = (int(camera_resolution[0]), int(camera_resolution[1]))
147 else:
148 raise ValueError("camera_resolution must be a tuple or list of 2 integers")
149
150
151 # Validate and set numeric properties
152 if focal_plane_distance <= 0:
153 raise ValueError("focal_plane_distance must be greater than 0")
154 if lens_diameter < 0:
155 raise ValueError("lens_diameter must be non-negative")
156 if HFOV <= 0 or HFOV > 180:
157 raise ValueError("HFOV must be between 0 and 180 degrees")
158 if FOV_aspect_ratio < 0:
159 raise ValueError("FOV_aspect_ratio must be non-negative (0 = auto-calculate)")
160 if lens_focal_length <= 0:
161 raise ValueError("lens_focal_length must be greater than 0")
162 if sensor_width_mm <= 0:
163 raise ValueError("sensor_width_mm must be greater than 0")
164 if shutter_speed <= 0:
165 raise ValueError("shutter_speed must be greater than 0")
166 if camera_zoom <= 0:
167 raise ValueError("camera_zoom must be greater than 0")
168
169 self.focal_plane_distance = float(focal_plane_distance)
170 self.lens_diameter = float(lens_diameter)
171 self.HFOV = float(HFOV)
172 self.FOV_aspect_ratio = float(FOV_aspect_ratio)
173 self.lens_focal_length = float(lens_focal_length)
174 self.sensor_width_mm = float(sensor_width_mm)
175 self.shutter_speed = float(shutter_speed)
176 self.camera_zoom = float(camera_zoom)
178 # Validate and set string properties
179 if not isinstance(manufacturer, str):
180 raise ValueError("manufacturer must be a string")
181 if not isinstance(model, str):
182 raise ValueError("model must be a string")
183 if not isinstance(lens_make, str):
184 raise ValueError("lens_make must be a string")
185 if not isinstance(lens_model, str):
186 raise ValueError("lens_model must be a string")
187 if not isinstance(lens_specification, str):
188 raise ValueError("lens_specification must be a string")
189 if not isinstance(exposure, str):
190 raise ValueError("exposure must be a string")
191 if not isinstance(white_balance, str):
192 raise ValueError("white_balance must be a string")
193
194 # Validate exposure mode
195 if exposure not in ["auto", "manual"] and not exposure.startswith("ISO"):
196 raise ValueError("exposure must be 'auto', 'manual', or 'ISOXXX' (e.g., 'ISO100')")
197
198 # Validate white balance mode
199 if white_balance not in ["auto", "off"]:
200 raise ValueError("white_balance must be 'auto' or 'off'")
201
202 self.manufacturer = str(manufacturer)
203 self.model = str(model)
204 self.lens_make = str(lens_make)
205 self.lens_model = str(lens_model)
206 self.lens_specification = str(lens_specification)
207 self.exposure = str(exposure)
208 self.white_balance = str(white_balance)
210 def to_array(self):
211 """
212 Convert to array format expected by C++ interface.
213
214 Note: Returns numeric fields only. String fields (model, lens_make, etc.) are
215 currently initialized with defaults in the C++ wrapper and cannot be set via
216 this interface. Use the upcoming camera library methods for full metadata control.
217
218 Returns:
219 List of 10 float values: [resolution_x, resolution_y, focal_distance, lens_diameter,
220 HFOV, FOV_aspect_ratio, lens_focal_length, sensor_width_mm,
221 shutter_speed, camera_zoom]
222 """
223 return [
224 float(self.camera_resolution[0]), # resolution_x
225 float(self.camera_resolution[1]), # resolution_y
227 self.lens_diameter,
228 self.HFOV,
229 self.FOV_aspect_ratio,
231 self.sensor_width_mm,
232 self.shutter_speed,
233 self.camera_zoom
234 ]
235
236 def __repr__(self):
237 return (f"CameraProperties("
238 f"camera_resolution={self.camera_resolution}, "
239 f"focal_plane_distance={self.focal_plane_distance}, "
240 f"lens_diameter={self.lens_diameter}, "
241 f"HFOV={self.HFOV}, "
242 f"FOV_aspect_ratio={self.FOV_aspect_ratio}, "
243 f"lens_focal_length={self.lens_focal_length}, "
244 f"sensor_width_mm={self.sensor_width_mm}, "
245 f"manufacturer='{self.manufacturer}', "
246 f"model='{self.model}', "
247 f"lens_make='{self.lens_make}', "
248 f"lens_model='{self.lens_model}', "
249 f"lens_specification='{self.lens_specification}', "
250 f"exposure='{self.exposure}', "
251 f"shutter_speed={self.shutter_speed}, "
252 f"white_balance='{self.white_balance}', "
253 f"camera_zoom={self.camera_zoom})")
254
255
257 """
258 Camera properties for a solar-induced chlorophyll fluorescence (SIF) camera.
259
260 Extends :class:`CameraProperties` with two SIF-specific fields used by the
261 Fluspect-B emission pipeline introduced in helios-core v1.3.72. Image geometry,
262 resolution, exposure, and spectral-response handling are inherited from
263 :class:`CameraProperties` unchanged.
264
265 Attributes:
266 excitation_bin_width_nm: Excitation wavelength bin width in nm. Helios
267 auto-creates internal radiation bands spanning 400–750 nm at this
268 resolution to compute per-leaf APAR. Must be > 0. Default 10.0.
269 excitation_scattering_depth: Scattering depth for the auto-generated
270 excitation bands. ``0`` (default) treats every leaf hit as fully
271 absorbed. Set to ``>=1`` to include inter-leaf scattering at the
272 cost of additional excitation-band ray traces.
273
274 Note:
275 String fields inherited from :class:`CameraProperties` (``model``,
276 ``lens_make``, ``lens_model``, ``lens_specification``, ``exposure``,
277 ``white_balance``) are currently NOT plumbed through to the C++ camera
278 — the wrapper hard-codes ``"generic"`` / ``"auto"`` defaults. Set them
279 on this dataclass for self-documentation only; they will not affect
280 rendering. This matches the existing ``addRadiationCamera`` behaviour.
281 """
282
283 def __init__(self, excitation_bin_width_nm: float = 10.0,
284 excitation_scattering_depth: int = 0,
285 **kwargs):
286 super().__init__(**kwargs)
287 if excitation_bin_width_nm <= 0:
288 raise ValueError("excitation_bin_width_nm must be greater than 0")
289 if excitation_scattering_depth < 0:
290 raise ValueError("excitation_scattering_depth must be >= 0")
291 self.excitation_bin_width_nm = float(excitation_bin_width_nm)
292 self.excitation_scattering_depth = int(excitation_scattering_depth)
293
294 def __repr__(self):
295 base = super().__repr__()
296 # Drop the trailing ')' from the parent repr and append SIF-specific fields.
297 return (
298 base[:-1]
299 + f", excitation_bin_width_nm={self.excitation_bin_width_nm}"
300 + f", excitation_scattering_depth={self.excitation_scattering_depth})"
301 )
303
304class CameraMetadata:
305 """
306 Metadata for radiation camera image export (Helios v1.3.58+).
307
308 This class encapsulates comprehensive metadata for camera images including
309 camera properties, location, acquisition settings, image processing, and
310 agronomic properties derived from plant architecture data.
311 """
312
313 class CameraPropertiesMetadata:
314 """Camera intrinsic properties for metadata export."""
315 def __init__(self, height=512, width=512, channels=3, type="rgb",
316 focal_length=50.0, aperture="f/2.8", sensor_width=35.0,
317 sensor_height=24.0, model="generic", lens_make="",
318 lens_model="", lens_specification="", exposure="auto",
319 shutter_speed=0.008, white_balance="auto"):
320 self.height = int(height)
321 self.width = int(width)
322 self.channels = int(channels)
323 self.type = str(type)
324 self.focal_length = float(focal_length)
325 self.aperture = str(aperture)
326 self.sensor_width = float(sensor_width)
327 self.sensor_height = float(sensor_height)
328 self.model = str(model)
329 self.lens_make = str(lens_make)
330 self.lens_model = str(lens_model)
331 self.lens_specification = str(lens_specification)
332 self.exposure = str(exposure)
333 self.shutter_speed = float(shutter_speed)
334 self.white_balance = str(white_balance)
337 """Geographic location properties."""
338 def __init__(self, latitude=0.0, longitude=0.0):
339 self.latitude = float(latitude)
340 self.longitude = float(longitude)
343 """Image acquisition properties."""
344 def __init__(self, date="", time="", UTC_offset=0.0, camera_height_m=0.0,
345 camera_angle_deg=0.0, light_source="sunlight"):
346 self.date = str(date)
347 self.time = str(time)
348 self.UTC_offset = float(UTC_offset)
349 self.camera_height_m = float(camera_height_m)
350 self.camera_angle_deg = float(camera_angle_deg)
351 self.light_source = str(light_source)
354 """Image processing corrections applied to the image."""
355 def __init__(self, saturation_adjustment=1.0, brightness_adjustment=1.0,
356 contrast_adjustment=1.0, color_space="linear"):
357 self.saturation_adjustment = float(saturation_adjustment)
358 self.brightness_adjustment = float(brightness_adjustment)
359 self.contrast_adjustment = float(contrast_adjustment)
360 self.color_space = str(color_space)
361
363 """Agronomic properties derived from plant architecture data."""
364 def __init__(self, plant_species=None, plant_count=None, plant_height_m=None,
365 plant_age_days=None, plant_stage=None, leaf_area_m2=None,
366 weed_pressure=""):
367 self.plant_species = plant_species if plant_species is not None else []
368 self.plant_count = plant_count if plant_count is not None else []
369 self.plant_height_m = plant_height_m if plant_height_m is not None else []
370 self.plant_age_days = plant_age_days if plant_age_days is not None else []
371 self.plant_stage = plant_stage if plant_stage is not None else []
372 self.leaf_area_m2 = leaf_area_m2 if leaf_area_m2 is not None else []
373 self.weed_pressure = str(weed_pressure)
374
375 def __init__(self, path=""):
376 """
377 Initialize CameraMetadata with default values.
379 Args:
380 path: Full path to the associated image file. Default: ""
381 """
382 self.path = str(path)
383 self.camera_properties = self.CameraPropertiesMetadata()
384 self.location_properties = self.LocationProperties()
385 self.acquisition_properties = self.AcquisitionProperties()
386 self.image_processing = self.ImageProcessingProperties()
387 self.agronomic_properties = self.AgronomicProperties()
388
389 def __repr__(self):
390 return (f"CameraMetadata(path='{self.path}', "
391 f"camera={self.camera_properties.model}, "
392 f"resolution={self.camera_properties.width}x{self.camera_properties.height}, "
393 f"location=({self.location_properties.latitude},{self.location_properties.longitude}))")
396class RadiationModel:
397 """
398 High-level interface for radiation modeling and ray tracing.
399
400 This class provides a user-friendly wrapper around the native Helios
401 radiation plugin with automatic plugin availability checking and
402 graceful error handling.
403 """
404
405 def __init__(self, context: Context):
406 """
407 Initialize RadiationModel with graceful plugin handling.
408
409 Args:
410 context: Helios Context instance
412 Raises:
413 TypeError: If context is not a Context instance
414 RadiationModelError: If radiation plugin is not available
415 """
416 # Validate context type
417 if not isinstance(context, Context):
418 raise TypeError(f"RadiationModel requires a Context instance, got {type(context).__name__}")
419
420 self.context = context
421 self.radiation_model = None
422 # Tracks whether scene geometry has been pushed to the radiation model via
423 # updateGeometry(). Some queries (e.g. calculateGtheta) silently return NaN
424 # from the native layer when no geometry is loaded; this flag lets us
425 # auto-update or fail with an actionable message instead.
426 self._geometry_updated = False
427
428 # Check plugin availability using registry
429 registry = get_plugin_registry()
430
431 if not registry.is_plugin_available('radiation'):
432 # Get helpful information about the missing plugin
433 plugin_info = registry.get_plugin_capabilities()
434 available_plugins = registry.get_available_plugins()
435
436 error_msg = (
437 "RadiationModel requires the 'radiation' plugin which is not available.\n\n"
438 "The radiation plugin provides GPU-accelerated ray tracing with runtime\n"
439 "backend auto-detection (OptiX 8 -> OptiX 6 -> Vulkan).\n"
440 "System requirements (at least one backend):\n"
441 "- Vulkan: Vulkan loader library (macOS/Linux); no extra packages on Windows\n"
442 "- OptiX 8.1: NVIDIA GPU with driver >= 560 and CUDA 12.0+\n"
443 "- OptiX 6.5: NVIDIA GPU with driver < 560 and CUDA 9.0+\n\n"
444 "To enable radiation modeling:\n"
445 "1. Build PyHelios with radiation plugin:\n"
446 " build_scripts/build_helios --plugins radiation\n"
447 "2. Or build with multiple plugins:\n"
448 " build_scripts/build_helios --plugins radiation,visualizer,weberpenntree\n"
449 f"\nCurrently available plugins: {available_plugins}"
450 )
451
452 # Suggest alternatives if available
453 alternatives = registry.suggest_alternatives('radiation')
454 if alternatives:
455 error_msg += f"\n\nAlternative plugins available: {alternatives}"
456 error_msg += "\nConsider using energybalance or leafoptics for thermal modeling."
457
458 raise RadiationModelError(error_msg)
459
460 # Plugin is available - create radiation model using working directory context manager
461 try:
463 self.radiation_model = radiation_wrapper.createRadiationModel(context.getNativePtr())
464 if self.radiation_model is None:
466 "Failed to create RadiationModel instance. "
467 "This may indicate a problem with the native library or GPU initialization."
468 )
469 logger.info("RadiationModel created successfully")
470
471 except Exception as e:
472 raise RadiationModelError(f"Failed to initialize RadiationModel: {e}")
473
474 def _check_context_alive(self):
475 """Raise if the owning Context has been destroyed (see Context.check_context_alive)."""
476 check_context_alive(getattr(self, "context", None), "RadiationModel")
477
478 def _check_camera_has_pixel_data(self, camera: str, bands: List[str], operation: str):
479 """Raise an actionable error if a camera/band has no rendered pixel data.
480
481 A camera's pixel data is populated only by ``runBand()``, and only for
482 the bands passed to that call and for cameras that already existed when
483 it ran. Requesting an image for an unrendered camera/band otherwise
484 reaches the native layer as a bare ``invalid map<K, T> key`` from
485 ``std::map::at`` -- see GitHub issue #4 and the upstream fix landing in
486 helios-core v1.3.79. This preflight turns that into a message naming the
487 camera, the band, and the call the user is missing.
488
489 Kept after the upstream fix lands: it stays correct (merely redundant)
490 against a fixed core, and users on earlier versions still need it.
491 """
492 try:
493 known_cameras = radiation_wrapper.getAllCameraLabels(self.radiation_model)
494 except Exception:
495 # Camera enumeration is only used to sharpen the message. If it is
496 # unavailable, fall through and let the per-band probe report.
497 known_cameras = None
498
499 if known_cameras is not None and camera not in known_cameras:
501 f"Cannot {operation}: camera '{camera}' does not exist. "
502 f"Add it with addRadiationCamera() before calling runBand(). "
503 f"Existing cameras: {sorted(known_cameras) if known_cameras else 'none'}"
504 )
505
506 for band in bands:
507 try:
508 radiation_wrapper.getCameraPixelData(self.radiation_model, camera, band)
509 except Exception:
511 f"Cannot {operation}: camera '{camera}' has no rendered pixel data "
512 f"for band '{band}'. Call runBand() with this band after adding the "
513 f"camera -- e.g. runBand({list(bands)!r}). Note that runBand() only "
514 f"renders the bands passed to it, and only for cameras that already "
515 f"exist when it runs."
516 ) from None
517
518 def __enter__(self):
519 """Context manager entry."""
520 return self
521
522 def __exit__(self, exc_type, exc_value, traceback):
523 """Context manager exit with proper cleanup."""
524 if self.radiation_model is not None:
525 try:
526 radiation_wrapper.destroyRadiationModel(self.radiation_model)
527 logger.debug("RadiationModel destroyed successfully")
528 except Exception as e:
529 logger.warning(f"Error destroying RadiationModel: {e}")
530 finally:
531 self.radiation_model = None # Prevent double deletion
532
533 def __del__(self):
534 """Destructor to ensure GPU resources freed even without 'with' statement."""
535 if hasattr(self, 'radiation_model') and self.radiation_model is not None:
536 try:
537 radiation_wrapper.destroyRadiationModel(self.radiation_model)
538 self.radiation_model = None
539 except Exception as e:
540 import warnings
541 warnings.warn(f"Error in RadiationModel.__del__: {e}")
542
543 def get_native_ptr(self):
544 """Get native pointer for advanced operations."""
545 return self.radiation_model
547 def getNativePtr(self):
548 """Get native pointer for advanced operations. (Legacy naming for compatibility)"""
549 return self.get_native_ptr()
550
551 @require_plugin('radiation', 'disable status messages')
552 def disableMessages(self):
553 """Disable RadiationModel status messages."""
555 radiation_wrapper.disableMessages(self.radiation_model)
557 @require_plugin('radiation', 'enable status messages')
558 def enableMessages(self):
559 """Enable RadiationModel status messages."""
561 radiation_wrapper.enableMessages(self.radiation_model)
562
563 @require_plugin('radiation', 'add radiation band')
564 def addRadiationBand(self, band_label: str, wavelength_min: float = None, wavelength_max: float = None):
565 """
566 Add radiation band with optional wavelength bounds.
567
568 Args:
569 band_label: Name/label for the radiation band
570 wavelength_min: Optional minimum wavelength (nm)
571 wavelength_max: Optional maximum wavelength (nm)
572 """
573 # Validate inputs
574 validate_band_label(band_label, "band_label", "addRadiationBand")
575 if wavelength_min is not None and wavelength_max is not None:
576 validate_wavelength_range(wavelength_min, wavelength_max, "wavelength_min", "wavelength_max", "addRadiationBand")
578 radiation_wrapper.addRadiationBandWithWavelengths(self.radiation_model, band_label, wavelength_min, wavelength_max)
579 logger.debug(f"Added radiation band {band_label}: {wavelength_min}-{wavelength_max} nm")
580 else:
582 radiation_wrapper.addRadiationBand(self.radiation_model, band_label)
583 logger.debug(f"Added radiation band: {band_label}")
585 @require_plugin('radiation', 'copy radiation band')
586 @validate_radiation_band_params
587 def copyRadiationBand(self, old_label: str, new_label: str, wavelength_min: float = None, wavelength_max: float = None):
588 """
589 Copy existing radiation band to new label, optionally with new wavelength range.
590
591 Args:
592 old_label: Existing band label to copy
593 new_label: New label for the copied band
594 wavelength_min: Optional minimum wavelength for new band (nm)
595 wavelength_max: Optional maximum wavelength for new band (nm)
596
597 Example:
598 >>> # Copy band with same wavelength range
599 >>> radiation.copyRadiationBand("SW", "SW_copy")
600 >>>
601 >>> # Copy band with different wavelength range
602 >>> radiation.copyRadiationBand("full_spectrum", "PAR", 400, 700)
603 """
604 if wavelength_min is not None and wavelength_max is not None:
605 validate_wavelength_range(wavelength_min, wavelength_max, "wavelength_min", "wavelength_max", "copyRadiationBand")
606
608 radiation_wrapper.copyRadiationBand(self.radiation_model, old_label, new_label, wavelength_min, wavelength_max)
609 if wavelength_min is not None:
610 logger.debug(f"Copied radiation band {old_label} to {new_label} with wavelengths {wavelength_min}-{wavelength_max} nm")
611 else:
612 logger.debug(f"Copied radiation band {old_label} to {new_label}")
613
614 @require_plugin('radiation', 'add radiation source')
615 @validate_collimated_source_params
616 def addCollimatedRadiationSource(self, direction=None) -> int:
617 """
618 Add collimated radiation source.
619
620 Args:
621 direction: Optional direction vector. Can be tuple (x, y, z), vec3, or None for default direction.
622
623 Returns:
624 Source ID
625 """
626 if direction is None:
628 source_id = radiation_wrapper.addCollimatedRadiationSourceDefault(self.radiation_model)
629 else:
630 # Handle vec3, SphericalCoord, and tuple types
631 if hasattr(direction, 'x') and hasattr(direction, 'y') and hasattr(direction, 'z'):
632 # vec3-like object
633 x, y, z = direction.x, direction.y, direction.z
634 elif hasattr(direction, 'radius') and hasattr(direction, 'elevation') and hasattr(direction, 'azimuth'):
635 # SphericalCoord object - convert to Cartesian
636 import math
637 r = direction.radius
638 elevation = direction.elevation
639 azimuth = direction.azimuth
640 x = r * math.cos(elevation) * math.cos(azimuth)
641 y = r * math.cos(elevation) * math.sin(azimuth)
642 z = r * math.sin(elevation)
643 else:
644 # Assume tuple-like object - validate it first
646 try:
647 if len(direction) != 3:
648 raise TypeError(f"Direction must be a 3-element tuple, vec3, or SphericalCoord, got {type(direction).__name__} with {len(direction)} elements")
649 x, y, z = direction
650 except (TypeError, AttributeError):
651 # Not a valid sequence type
652 raise TypeError(f"Direction must be a tuple, vec3, or SphericalCoord, got {type(direction).__name__}")
653 source_id = radiation_wrapper.addCollimatedRadiationSourceVec3(self.radiation_model, x, y, z)
654
655 logger.debug(f"Added collimated radiation source: ID {source_id}")
656 return source_id
657
658 @require_plugin('radiation', 'add spherical radiation source')
659 @validate_sphere_source_params
660 def addSphereRadiationSource(self, position, radius: float) -> int:
661 """
662 Add spherical radiation source.
663
664 Args:
665 position: Position of the source. Can be tuple (x, y, z) or vec3.
666 radius: Radius of the spherical source
667
668 Returns:
669 Source ID
670 """
671 validate_position_like(position, "position", "addSphereRadiationSource")
672 # Handle both tuple and vec3 types
673 if hasattr(position, 'x') and hasattr(position, 'y') and hasattr(position, 'z'):
674 x, y, z = position.x, position.y, position.z
675 else:
676 x, y, z = position
678 source_id = radiation_wrapper.addSphereRadiationSource(self.radiation_model, x, y, z, radius)
679 logger.debug(f"Added sphere radiation source: ID {source_id} at ({x}, {y}, {z}) with radius {radius}")
680 return source_id
681
682 @require_plugin('radiation', 'add sun radiation source')
683 @validate_sun_sphere_params
684 def addSunSphereRadiationSource(self, radius: float, zenith: float, azimuth: float,
685 position_scaling: float = 1.0, angular_width: float = 0.53,
686 flux_scaling: float = 1.0) -> int:
687 """
688 Add sun sphere radiation source.
689
690 Args:
691 radius: Radius of the sun sphere
692 zenith: Zenith angle (degrees)
693 azimuth: Azimuth angle (degrees)
694 position_scaling: Position scaling factor
695 angular_width: Angular width of the sun (degrees)
696 flux_scaling: Flux scaling factor
697
698 Returns:
699 Source ID
700 """
702 source_id = radiation_wrapper.addSunSphereRadiationSource(
703 self.radiation_model, radius, zenith, azimuth, position_scaling, angular_width, flux_scaling
704 )
705 logger.debug(f"Added sun radiation source: ID {source_id}")
706 return source_id
707
708 @require_plugin('radiation', 'set source position')
709 def setSourcePosition(self, source_id: int, position):
710 """
711 Set position of a radiation source.
712
713 Allows dynamic repositioning of radiation sources during simulation,
714 useful for time-series modeling or moving light sources.
715
716 Args:
717 source_id: ID of the radiation source
718 position: New position as vec3, SphericalCoord, or list/tuple [x, y, z]
719
720 Example:
721 >>> source_id = radiation.addCollimatedRadiationSource()
722 >>> radiation.setSourcePosition(source_id, [10, 20, 30])
723 >>> from pyhelios.types import vec3
724 >>> radiation.setSourcePosition(source_id, vec3(15, 25, 35))
725 """
726 if not isinstance(source_id, int) or source_id < 0:
727 raise ValueError(f"Source ID must be a non-negative integer, got {source_id}")
728 validate_direction_like(position, "position", "setSourcePosition")
730 radiation_wrapper.setSourcePosition(self.radiation_model, source_id, position)
731 logger.debug(f"Updated position for radiation source {source_id}")
732
733 @require_plugin('radiation', 'add rectangle radiation source')
734 def addRectangleRadiationSource(self, position, size, rotation) -> int:
735 """
736 Add a rectangle (planar) radiation source.
738 Rectangle sources are ideal for modeling artificial lighting such as
739 LED panels, grow lights, or window light sources.
740
741 Args:
742 position: Center position as vec3 or list [x, y, z]
743 size: Rectangle dimensions as vec2 or list [width, height]
744 rotation: Rotation vector as vec3 or list [rx, ry, rz] (Euler angles in radians)
745
746 Returns:
747 Source ID
748
749 Example:
750 >>> from pyhelios.types import vec3, vec2
751 >>> source_id = radiation.addRectangleRadiationSource(
752 ... position=vec3(0, 0, 5),
753 ... size=vec2(2, 1),
754 ... rotation=vec3(0, 0, 0)
755 ... )
756 >>> radiation.setSourceFlux(source_id, "PAR", 500.0)
757 """
758 validate_position_like(position, "position", "addRectangleRadiationSource")
759 validate_size_like(size, "size", "addRectangleRadiationSource")
760 validate_position_like(rotation, "rotation", "addRectangleRadiationSource")
762 return radiation_wrapper.addRectangleRadiationSource(self.radiation_model, position, size, rotation)
763
764 @require_plugin('radiation', 'add disk radiation source')
765 def addDiskRadiationSource(self, position, radius: float, rotation) -> int:
766 """
767 Add a disk (circular planar) radiation source.
768
769 Disk sources are useful for modeling circular light sources such as
770 spotlights, circular LED arrays, or solar simulators.
771
772 Args:
773 position: Center position as vec3 or list [x, y, z]
774 radius: Disk radius
775 rotation: Rotation vector as vec3 or list [rx, ry, rz] (Euler angles in radians)
776
777 Returns:
778 Source ID
779
780 Example:
781 >>> from pyhelios.types import vec3
782 >>> source_id = radiation.addDiskRadiationSource(
783 ... position=vec3(0, 0, 5),
784 ... radius=1.5,
785 ... rotation=vec3(0, 0, 0)
786 ... )
787 >>> radiation.setSourceFlux(source_id, "PAR", 300.0)
788 """
789 validate_position_like(position, "position", "addDiskRadiationSource")
790 validate_position_like(rotation, "rotation", "addDiskRadiationSource")
791 if radius <= 0:
792 raise ValueError(f"Radius must be positive, got {radius}")
794 return radiation_wrapper.addDiskRadiationSource(self.radiation_model, position, radius, rotation)
795
796 # Source spectrum methods
797 @require_plugin('radiation', 'manage source spectrum')
798 def setSourceSpectrum(self, source_id, spectrum):
799 """
800 Set radiation spectrum for source(s).
801
802 Spectral distributions define how radiation intensity varies with wavelength,
803 essential for realistic modeling of different light sources (sunlight, LEDs, etc.).
804
805 Args:
806 source_id: Source ID (int) or list of source IDs
807 spectrum: Either:
808 - Spectrum data as list of (wavelength, value) tuples
809 - Global data label string
810
811 Example:
812 >>> # Define custom LED spectrum
813 >>> led_spectrum = [
814 ... (400, 0.0), (450, 0.3), (500, 0.8),
815 ... (550, 0.5), (600, 0.2), (700, 0.0)
816 ... ]
817 >>> radiation.setSourceSpectrum(source_id, led_spectrum)
818 >>>
819 >>> # Use predefined spectrum from global data
820 >>> radiation.setSourceSpectrum(source_id, "D65_illuminant")
821 >>>
822 >>> # Apply same spectrum to multiple sources
823 >>> radiation.setSourceSpectrum([src1, src2, src3], led_spectrum)
824 """
826 radiation_wrapper.setSourceSpectrum(self.radiation_model, source_id, spectrum)
827 logger.debug(f"Set spectrum for source(s) {source_id}")
828
829 @require_plugin('radiation', 'configure source spectrum')
830 def setSourceSpectrumIntegral(self, source_id: int, source_integral: float,
831 wavelength_min: float = None, wavelength_max: float = None):
832 """
833 Set source spectrum integral value.
834
835 Normalizes the spectrum so that its integral equals the specified value,
836 useful for calibrating source intensity.
837
838 Args:
839 source_id: Source ID
840 source_integral: Target integral value
841 wavelength_min: Optional minimum wavelength for integration range
842 wavelength_max: Optional maximum wavelength for integration range
843
844 Example:
845 >>> radiation.setSourceSpectrumIntegral(source_id, 1000.0)
846 >>> radiation.setSourceSpectrumIntegral(source_id, 500.0, 400, 700) # PAR range
847 """
848 if not isinstance(source_id, int) or source_id < 0:
849 raise ValueError(f"Source ID must be a non-negative integer, got {source_id}")
850 if source_integral < 0:
851 raise ValueError(f"Source integral must be non-negative, got {source_integral}")
852
854 radiation_wrapper.setSourceSpectrumIntegral(self.radiation_model, source_id, source_integral,
855 wavelength_min, wavelength_max)
856 logger.debug(f"Set spectrum integral for source {source_id}: {source_integral}")
857
858 # Spectrum integration and analysis methods
859 @require_plugin('radiation', 'integrate spectrum')
860 def integrateSpectrum(self, object_spectrum, wavelength_min: float = None,
861 wavelength_max: float = None, source_id: int = None,
862 camera_spectrum=None) -> float:
863 """
864 Integrate spectrum with optional source/camera spectra and wavelength range.
865
866 This unified method handles multiple integration scenarios:
867 - Basic: Total spectrum integration
868 - Range: Integration over wavelength range
869 - Source: Integration weighted by source spectrum
870 - Camera: Integration weighted by camera spectral response
871 - Full: Integration with both source and camera spectra
872
873 Args:
874 object_spectrum: Object spectrum as list of (wavelength, value) tuples/vec2
875 wavelength_min: Optional minimum wavelength for integration range
876 wavelength_max: Optional maximum wavelength for integration range
877 source_id: Optional source ID for source spectrum weighting
878 camera_spectrum: Optional camera spectrum for camera response weighting
879
880 Returns:
881 Integrated value
882
883 Example:
884 >>> leaf_reflectance = [(400, 0.1), (500, 0.4), (600, 0.6), (700, 0.5)]
885 >>>
886 >>> # Total integration
887 >>> total = radiation.integrateSpectrum(leaf_reflectance)
888 >>>
889 >>> # PAR range (400-700nm)
890 >>> par = radiation.integrateSpectrum(leaf_reflectance, 400, 700)
891 >>>
892 >>> # With source spectrum
893 >>> source_weighted = radiation.integrateSpectrum(
894 ... leaf_reflectance, 400, 700, source_id=sun_source
895 ... )
896 >>>
897 >>> # With camera response
898 >>> camera_response = [(400, 0.2), (550, 1.0), (700, 0.3)]
899 >>> camera_weighted = radiation.integrateSpectrum(
900 ... leaf_reflectance, camera_spectrum=camera_response
901 ... )
902 """
904 return radiation_wrapper.integrateSpectrum(self.radiation_model, object_spectrum,
905 wavelength_min, wavelength_max,
906 source_id, camera_spectrum)
907
908 @require_plugin('radiation', 'integrate source spectrum')
909 def integrateSourceSpectrum(self, source_id: int, wavelength_min: float, wavelength_max: float) -> float:
910 """
911 Integrate source spectrum over wavelength range.
913 Args:
914 source_id: Source ID
915 wavelength_min: Minimum wavelength
916 wavelength_max: Maximum wavelength
917
918 Returns:
919 Integrated source spectrum value
920
921 Example:
922 >>> par_flux = radiation.integrateSourceSpectrum(source_id, 400, 700)
923 """
924 if not isinstance(source_id, int) or source_id < 0:
925 raise ValueError(f"Source ID must be a non-negative integer, got {source_id}")
927 return radiation_wrapper.integrateSourceSpectrum(self.radiation_model, source_id,
928 wavelength_min, wavelength_max)
929
930 # Spectral manipulation methods
931 @require_plugin('radiation', 'scale spectrum')
932 def scaleSpectrum(self, existing_label: str, new_label_or_scale, scale_factor: float = None):
933 """
934 Scale spectrum in-place or to new label.
936 Useful for adjusting spectrum intensities or creating variations of
937 existing spectra for sensitivity analysis.
938
939 Supports two call patterns:
940 - scaleSpectrum("label", scale) -> scales in-place
941 - scaleSpectrum("existing", "new", scale) -> creates new scaled spectrum
942
943 Args:
944 existing_label: Existing global data label
945 new_label_or_scale: Either new label string (if creating new) or scale factor (if in-place)
946 scale_factor: Scale factor (required only if new_label_or_scale is a string)
947
948 Example:
949 >>> # In-place scaling
950 >>> radiation.scaleSpectrum("leaf_reflectance", 1.2)
951 >>>
952 >>> # Create new scaled spectrum
953 >>> radiation.scaleSpectrum("leaf_reflectance", "scaled_leaf", 1.5)
954 """
955 if not isinstance(existing_label, str) or not existing_label.strip():
956 raise ValueError("Existing label must be a non-empty string")
957
959 radiation_wrapper.scaleSpectrum(self.radiation_model, existing_label,
960 new_label_or_scale, scale_factor)
961 logger.debug(f"Scaled spectrum '{existing_label}'")
962
963 @require_plugin('radiation', 'scale spectrum randomly')
964 def scaleSpectrumRandomly(self, existing_label: str, new_label: str,
965 min_scale: float, max_scale: float):
966 """
967 Scale spectrum with random factor and store as new label.
968
969 Useful for creating stochastic variations in spectral properties for
970 Monte Carlo simulations or uncertainty quantification.
971
972 Args:
973 existing_label: Existing global data label
974 new_label: New global data label for scaled spectrum
975 min_scale: Minimum scale factor
976 max_scale: Maximum scale factor
977
978 Example:
979 >>> # Create random variation of leaf reflectance
980 >>> radiation.scaleSpectrumRandomly("leaf_base", "leaf_variant", 0.8, 1.2)
981 """
982 if not isinstance(existing_label, str) or not existing_label.strip():
983 raise ValueError("Existing label must be a non-empty string")
984 if not isinstance(new_label, str) or not new_label.strip():
985 raise ValueError("New label must be a non-empty string")
986 if min_scale >= max_scale:
987 raise ValueError(f"min_scale ({min_scale}) must be less than max_scale ({max_scale})")
988
990 radiation_wrapper.scaleSpectrumRandomly(self.radiation_model, existing_label, new_label,
991 min_scale, max_scale)
992 logger.debug(f"Scaled spectrum '{existing_label}' randomly to '{new_label}'")
993
994 @require_plugin('radiation', 'blend spectra')
995 def blendSpectra(self, new_label: str, spectrum_labels: List[str], weights: List[float]):
996 """
997 Blend multiple spectra with specified weights.
998
999 Creates weighted combination of spectra, useful for mixing material properties
1000 or creating composite light sources.
1001
1002 Args:
1003 new_label: New global data label for blended spectrum
1004 spectrum_labels: List of spectrum labels to blend
1005 weights: List of weights (must sum to reasonable values, same length as labels)
1006
1007 Example:
1008 >>> # Mix two leaf types (70% type A, 30% type B)
1009 >>> radiation.blendSpectra("mixed_leaf",
1010 ... ["leaf_type_a", "leaf_type_b"],
1011 ... [0.7, 0.3]
1012 ... )
1013 """
1014 if not isinstance(new_label, str) or not new_label.strip():
1015 raise ValueError("New label must be a non-empty string")
1016 if len(spectrum_labels) != len(weights):
1017 raise ValueError(f"Number of labels ({len(spectrum_labels)}) must match number of weights ({len(weights)})")
1018 if not spectrum_labels:
1019 raise ValueError("At least one spectrum label required")
1020
1022 radiation_wrapper.blendSpectra(self.radiation_model, new_label, spectrum_labels, weights)
1023 logger.debug(f"Blended {len(spectrum_labels)} spectra into '{new_label}'")
1024
1025 @require_plugin('radiation', 'blend spectra randomly')
1026 def blendSpectraRandomly(self, new_label: str, spectrum_labels: List[str]):
1027 """
1028 Blend multiple spectra with random weights.
1029
1030 Creates random combinations of spectra, useful for generating diverse
1031 material properties in stochastic simulations.
1032
1033 Args:
1034 new_label: New global data label for blended spectrum
1035 spectrum_labels: List of spectrum labels to blend
1036
1037 Example:
1038 >>> # Create random mixture of leaf spectra
1039 >>> radiation.blendSpectraRandomly("random_leaf",
1040 ... ["young_leaf", "mature_leaf", "senescent_leaf"]
1041 ... )
1042 """
1043 if not isinstance(new_label, str) or not new_label.strip():
1044 raise ValueError("New label must be a non-empty string")
1045 if not spectrum_labels:
1046 raise ValueError("At least one spectrum label required")
1047
1049 radiation_wrapper.blendSpectraRandomly(self.radiation_model, new_label, spectrum_labels)
1050 logger.debug(f"Blended {len(spectrum_labels)} spectra randomly into '{new_label}'")
1051
1052 # Spectral interpolation methods
1053 @require_plugin('radiation', 'interpolate spectrum from data')
1054 def interpolateSpectrumFromPrimitiveData(self, primitive_uuids: List[int],
1055 spectra_labels: List[str], values: List[float],
1056 primitive_data_query_label: str,
1057 primitive_data_radprop_label: str):
1058 """
1059 Interpolate spectral properties based on primitive data values.
1060
1061 Automatically assigns spectra to primitives by interpolating between
1062 reference spectra based on continuous data values (e.g., age, moisture, etc.).
1063
1064 Args:
1065 primitive_uuids: List of primitive UUIDs to assign spectra
1066 spectra_labels: List of reference spectrum labels
1067 values: List of data values corresponding to each spectrum
1068 primitive_data_query_label: Primitive data label containing query values
1069 primitive_data_radprop_label: Primitive data label to store assigned spectra
1070
1071 Example:
1072 >>> # Assign leaf reflectance based on age
1073 >>> leaf_patches = context.getAllUUIDs("patch")
1074 >>> radiation.interpolateSpectrumFromPrimitiveData(
1075 ... primitive_uuids=leaf_patches,
1076 ... spectra_labels=["young_leaf", "mature_leaf", "old_leaf"],
1077 ... values=[0.0, 50.0, 100.0], # Days since emergence
1078 ... primitive_data_query_label="leaf_age",
1079 ... primitive_data_radprop_label="reflectance"
1080 ... )
1081 """
1082 if not isinstance(primitive_uuids, (list, tuple)) or not primitive_uuids:
1083 raise ValueError("Primitive UUIDs must be a non-empty list")
1084 if not isinstance(spectra_labels, (list, tuple)) or not spectra_labels:
1085 raise ValueError("Spectra labels must be a non-empty list")
1086 if not isinstance(values, (list, tuple)) or not values:
1087 raise ValueError("Values must be a non-empty list")
1088 if len(spectra_labels) != len(values):
1089 raise ValueError(f"Number of spectra ({len(spectra_labels)}) must match number of values ({len(values)})")
1092 radiation_wrapper.interpolateSpectrumFromPrimitiveData(
1093 self.radiation_model, primitive_uuids, spectra_labels, values,
1094 primitive_data_query_label, primitive_data_radprop_label
1095 )
1096 logger.debug(f"Interpolated spectra for {len(primitive_uuids)} primitives")
1097
1098 @require_plugin('radiation', 'interpolate spectrum from object data')
1099 def interpolateSpectrumFromObjectData(self, object_ids: List[int],
1100 spectra_labels: List[str], values: List[float],
1101 object_data_query_label: str,
1102 primitive_data_radprop_label: str):
1103 """
1104 Interpolate spectral properties based on object data values.
1105
1106 Automatically assigns spectra to object primitives by interpolating between
1107 reference spectra based on continuous object-level data values.
1108
1109 Args:
1110 object_ids: List of object IDs
1111 spectra_labels: List of reference spectrum labels
1112 values: List of data values corresponding to each spectrum
1113 object_data_query_label: Object data label containing query values
1114 primitive_data_radprop_label: Primitive data label to store assigned spectra
1115
1116 Example:
1117 >>> # Assign tree reflectance based on health index
1118 >>> tree_ids = [tree1_id, tree2_id, tree3_id]
1119 >>> radiation.interpolateSpectrumFromObjectData(
1120 ... object_ids=tree_ids,
1121 ... spectra_labels=["healthy_tree", "stressed_tree", "diseased_tree"],
1122 ... values=[1.0, 0.5, 0.0], # Health index
1123 ... object_data_query_label="health_index",
1124 ... primitive_data_radprop_label="reflectance"
1125 ... )
1126 """
1127 if not isinstance(object_ids, (list, tuple)) or not object_ids:
1128 raise ValueError("Object IDs must be a non-empty list")
1129 if not isinstance(spectra_labels, (list, tuple)) or not spectra_labels:
1130 raise ValueError("Spectra labels must be a non-empty list")
1131 if not isinstance(values, (list, tuple)) or not values:
1132 raise ValueError("Values must be a non-empty list")
1133 if len(spectra_labels) != len(values):
1134 raise ValueError(f"Number of spectra ({len(spectra_labels)}) must match number of values ({len(values)})")
1137 radiation_wrapper.interpolateSpectrumFromObjectData(
1138 self.radiation_model, object_ids, spectra_labels, values,
1139 object_data_query_label, primitive_data_radprop_label
1140 )
1141 logger.debug(f"Interpolated spectra for {len(object_ids)} objects")
1142
1143 @require_plugin('radiation', 'set ray count')
1144 def setDirectRayCount(self, band_label: str, ray_count: int):
1145 """Set direct ray count for radiation band."""
1146 validate_band_label(band_label, "band_label", "setDirectRayCount")
1147 validate_ray_count(ray_count, "ray_count", "setDirectRayCount")
1149 radiation_wrapper.setDirectRayCount(self.radiation_model, band_label, ray_count)
1150
1151 @require_plugin('radiation', 'set ray count')
1152 def setDiffuseRayCount(self, band_label: str, ray_count: int):
1153 """Set diffuse ray count for radiation band."""
1154 validate_band_label(band_label, "band_label", "setDiffuseRayCount")
1155 validate_ray_count(ray_count, "ray_count", "setDiffuseRayCount")
1157 radiation_wrapper.setDiffuseRayCount(self.radiation_model, band_label, ray_count)
1158
1159 @require_plugin('radiation', 'set radiation flux')
1160 def setDiffuseRadiationFlux(self, label: str, flux: float):
1161 """Set diffuse radiation flux for band."""
1162 validate_band_label(label, "label", "setDiffuseRadiationFlux")
1163 validate_flux_value(flux, "flux", "setDiffuseRadiationFlux")
1165 radiation_wrapper.setDiffuseRadiationFlux(self.radiation_model, label, flux)
1166
1167 @require_plugin('radiation', 'configure diffuse radiation')
1168 def setDiffuseRadiationExtinctionCoeff(self, label: str, K: float, peak_direction):
1169 """
1170 Set diffuse radiation extinction coefficient with directional bias.
1171
1172 Models directionally-biased diffuse radiation (e.g., sky radiation with zenith peak).
1174 Args:
1175 label: Band label
1176 K: Extinction coefficient
1177 peak_direction: Peak direction as vec3, SphericalCoord, or list [x, y, z]
1178
1179 Example:
1180 >>> from pyhelios.types import vec3
1181 >>> radiation.setDiffuseRadiationExtinctionCoeff("SW", 0.5, vec3(0, 0, 1))
1182 """
1183 validate_band_label(label, "label", "setDiffuseRadiationExtinctionCoeff")
1184 if K < 0:
1185 raise ValueError(f"Extinction coefficient must be non-negative, got {K}")
1186 validate_direction_like(peak_direction, "peak_direction", "setDiffuseRadiationExtinctionCoeff")
1188 radiation_wrapper.setDiffuseRadiationExtinctionCoeff(self.radiation_model, label, K, peak_direction)
1189 logger.debug(f"Set diffuse extinction coefficient for band '{label}': K={K}")
1190
1191 @require_plugin('radiation', 'query diffuse flux')
1192 def getDiffuseFlux(self, band_label: str) -> float:
1193 """
1194 Get diffuse flux for band.
1195
1196 Args:
1197 band_label: Band label
1198
1199 Returns:
1200 Diffuse flux value
1201
1202 Example:
1203 >>> flux = radiation.getDiffuseFlux("SW")
1204 """
1205 validate_band_label(band_label, "band_label", "getDiffuseFlux")
1207 return radiation_wrapper.getDiffuseFlux(self.radiation_model, band_label)
1208
1209 @require_plugin('radiation', 'configure diffuse spectrum')
1210 def setDiffuseSpectrum(self, band_label, spectrum_label: str):
1211 """
1212 Set diffuse spectrum from global data label.
1213
1214 Args:
1215 band_label: Band label (string) or list of band labels
1216 spectrum_label: Spectrum global data label
1217
1218 Example:
1219 >>> radiation.setDiffuseSpectrum("SW", "sky_spectrum")
1220 >>> radiation.setDiffuseSpectrum(["SW", "NIR"], "sky_spectrum")
1221 """
1222 if isinstance(band_label, str):
1223 validate_band_label(band_label, "band_label", "setDiffuseSpectrum")
1224 else:
1225 for label in band_label:
1226 validate_band_label(label, "band_label", "setDiffuseSpectrum")
1227 if not isinstance(spectrum_label, str) or not spectrum_label.strip():
1228 raise ValueError("Spectrum label must be a non-empty string")
1229
1231 radiation_wrapper.setDiffuseSpectrum(self.radiation_model, band_label, spectrum_label)
1232 logger.debug(f"Set diffuse spectrum for band(s) {band_label}")
1234 @require_plugin('radiation', 'configure diffuse spectrum')
1235 def setDiffuseSpectrumIntegral(self, spectrum_integral: float, wavelength_min: float = None,
1236 wavelength_max: float = None, band_label: str = None):
1237 """
1238 Set diffuse spectrum integral.
1239
1240 Args:
1241 spectrum_integral: Integral value
1242 wavelength_min: Optional minimum wavelength
1243 wavelength_max: Optional maximum wavelength
1244 band_label: Optional specific band label (None for all bands)
1245
1246 Example:
1247 >>> radiation.setDiffuseSpectrumIntegral(1000.0) # All bands
1248 >>> radiation.setDiffuseSpectrumIntegral(500.0, 400, 700, band_label="PAR") # Specific band
1249 """
1250 if spectrum_integral < 0:
1251 raise ValueError(f"Spectrum integral must be non-negative, got {spectrum_integral}")
1252 if band_label is not None:
1253 validate_band_label(band_label, "band_label", "setDiffuseSpectrumIntegral")
1254
1256 radiation_wrapper.setDiffuseSpectrumIntegral(self.radiation_model, spectrum_integral,
1257 wavelength_min, wavelength_max, band_label)
1258 logger.debug(f"Set diffuse spectrum integral: {spectrum_integral}")
1259
1260 @require_plugin('radiation', 'set source flux')
1261 def setSourceFlux(self, source_id, label: str, flux: float):
1262 """Set source flux for single source or multiple sources."""
1263 validate_band_label(label, "label", "setSourceFlux")
1264 validate_flux_value(flux, "flux", "setSourceFlux")
1265
1266 if isinstance(source_id, (list, tuple)):
1267 # Multiple sources
1268 validate_source_id_list(list(source_id), "source_id", "setSourceFlux")
1270 radiation_wrapper.setSourceFluxMultiple(self.radiation_model, source_id, label, flux)
1271 else:
1272 # Single source
1273 validate_source_id(source_id, "source_id", "setSourceFlux")
1275 radiation_wrapper.setSourceFlux(self.radiation_model, source_id, label, flux)
1276
1277
1278 @require_plugin('radiation', 'get source flux')
1279 @validate_get_source_flux_params
1280 def getSourceFlux(self, source_id: int, label: str) -> float:
1281 """Get source flux for band."""
1283 return radiation_wrapper.getSourceFlux(self.radiation_model, source_id, label)
1284
1285 @require_plugin('radiation', 'update geometry')
1286 @validate_update_geometry_params
1287 def updateGeometry(self, uuids: Optional[List[int]] = None):
1288 """
1289 Update geometry in radiation model.
1290
1291 Args:
1292 uuids: Optional list of specific UUIDs to update. If None, updates all geometry.
1293 """
1294 if uuids is None:
1296 radiation_wrapper.updateGeometry(self.radiation_model)
1297 logger.debug("Updated all geometry in radiation model")
1298 else:
1300 radiation_wrapper.updateGeometryUUIDs(self.radiation_model, uuids)
1301 logger.debug(f"Updated {len(uuids)} geometry UUIDs in radiation model")
1302 self._geometry_updated = True
1303
1304 @require_plugin('radiation', 'run radiation simulation')
1305 @validate_run_band_params
1306 def runBand(self, band_label):
1307 """
1308 Run radiation simulation for single band or multiple bands.
1309
1310 PERFORMANCE NOTE: When simulating multiple radiation bands, it is HIGHLY RECOMMENDED
1311 to run all bands in a single call (e.g., runBand(["PAR", "NIR", "SW"])) rather than
1312 sequential single-band calls. This provides significant computational efficiency gains
1313 because:
1314
1315 - GPU ray tracing setup is done once for all bands
1316 - Scene geometry acceleration structures are reused
1317 - GPU kernel launches are batched together
1318 - Memory transfers between CPU/GPU are minimized
1319
1320 Example:
1321 # EFFICIENT - Single call for multiple bands
1322 radiation.runBand(["PAR", "NIR", "SW"])
1323
1324 # INEFFICIENT - Sequential single-band calls
1325 radiation.runBand("PAR")
1326 radiation.runBand("NIR")
1327 radiation.runBand("SW")
1328
1329 Args:
1330 band_label: Single band name (str) or list of band names for multi-band simulation
1331 """
1332 if isinstance(band_label, (list, tuple)):
1333 # Multiple bands - validate each label
1334 for lbl in band_label:
1335 if not isinstance(lbl, str):
1336 raise TypeError(f"Band labels must be strings, got {type(lbl).__name__}")
1338 radiation_wrapper.runBandMultiple(self.radiation_model, band_label)
1339 logger.info(f"Completed radiation simulation for bands: {band_label}")
1340 else:
1341 # Single band - validate label type
1342 if not isinstance(band_label, str):
1343 raise TypeError(f"Band label must be a string, got {type(band_label).__name__}")
1345 radiation_wrapper.runBand(self.radiation_model, band_label)
1346 logger.info(f"Completed radiation simulation for band: {band_label}")
1347
1348
1349 @require_plugin('radiation', 'get simulation results')
1350 def getTotalAbsorbedFlux(self) -> List[float]:
1351 """Get absorbed radiation flux density for all primitives, summed over all bands.
1352
1353 Returns one value per primitive, in the Context's primitive order (matching
1354 ``context.getAllUUIDs()``).
1355
1356 Units are **W/m^2** (flux density), not watts. This is the sum of the
1357 ``radiation_flux_<band>`` primitive data over every band added to the model.
1358 Because it is a density, the value does not change when a primitive's size
1359 changes: a 1x1 m and a 2x2 m patch under the same collimated source both
1360 report the same number.
1361
1362 To obtain absorbed power in watts, weight each primitive by its area::
1363
1364 flux = radiation.getTotalAbsorbedFlux()
1365 power = sum(f * context.getPrimitiveArea(u)
1366 for f, u in zip(flux, context.getAllUUIDs()))
1367
1368 Summing the returned values directly (``sum(flux)``) adds flux densities of
1369 differently-sized surfaces and is not physically meaningful.
1370
1371 Returns:
1372 Absorbed flux density per primitive in W/m^2.
1373 """
1375 results = radiation_wrapper.getTotalAbsorbedFlux(self.radiation_model)
1376 logger.debug(f"Retrieved absorbed flux data for {len(results)} primitives")
1377 return results
1378
1379 # Band query methods
1380 @require_plugin('radiation', 'check band existence')
1381 def doesBandExist(self, label: str) -> bool:
1382 """
1383 Check if a radiation band exists.
1384
1385 Args:
1386 label: Name/label of the radiation band to check
1387
1388 Returns:
1389 True if band exists, False otherwise
1390
1391 Example:
1392 >>> radiation.addRadiationBand("SW")
1393 >>> radiation.doesBandExist("SW")
1394 True
1395 >>> radiation.doesBandExist("nonexistent")
1396 False
1397 """
1398 validate_band_label(label, "label", "doesBandExist")
1400 return radiation_wrapper.doesBandExist(self.radiation_model, label)
1401
1402 # Advanced source management methods
1403 @require_plugin('radiation', 'manage radiation sources')
1404 def deleteRadiationSource(self, source_id: int):
1405 """
1406 Delete a radiation source.
1407
1408 Args:
1409 source_id: ID of the radiation source to delete
1410
1411 Example:
1412 >>> source_id = radiation.addCollimatedRadiationSource()
1413 >>> radiation.deleteRadiationSource(source_id)
1414 """
1415 if not isinstance(source_id, int) or source_id < 0:
1416 raise ValueError(f"Source ID must be a non-negative integer, got {source_id}")
1418 radiation_wrapper.deleteRadiationSource(self.radiation_model, source_id)
1419 logger.debug(f"Deleted radiation source {source_id}")
1420
1421 @require_plugin('radiation', 'query radiation sources')
1422 def getSourcePosition(self, source_id: int):
1423 """
1424 Get position of a radiation source.
1425
1426 Args:
1427 source_id: ID of the radiation source
1428
1429 Returns:
1430 vec3 position of the source
1431
1432 Example:
1433 >>> source_id = radiation.addCollimatedRadiationSource()
1434 >>> position = radiation.getSourcePosition(source_id)
1435 >>> print(f"Source at: {position}")
1436 """
1437 if not isinstance(source_id, int) or source_id < 0:
1438 raise ValueError(f"Source ID must be a non-negative integer, got {source_id}")
1440 position_list = radiation_wrapper.getSourcePosition(self.radiation_model, source_id)
1441 from .wrappers.DataTypes import vec3
1442 return vec3(position_list[0], position_list[1], position_list[2])
1443
1444 # Advanced simulation methods
1445 @require_plugin('radiation', 'get sky energy')
1446 def getSkyEnergy(self) -> float:
1447 """
1448 Get total sky energy.
1449
1450 Returns:
1451 Total sky energy value
1452
1453 Example:
1454 >>> energy = radiation.getSkyEnergy()
1455 >>> print(f"Sky energy: {energy}")
1456 """
1458 return radiation_wrapper.getSkyEnergy(self.radiation_model)
1459
1460 @require_plugin('radiation', 'calculate G-function')
1461 def calculateGtheta(self, view_direction) -> float:
1462 """
1463 Calculate G-function (geometry factor) for given view direction.
1464
1465 The G-function describes the geometric relationship between leaf area
1466 distribution and viewing direction, important for canopy radiation modeling.
1467
1468 The G-function is computed from the geometry currently loaded in the radiation
1469 model. If updateGeometry() has not yet been called, this method calls it
1470 automatically (with a warning) so the query operates on the current context
1471 geometry. If the result is still undefined (no primitives / zero leaf area),
1472 a RuntimeError is raised rather than silently returning NaN.
1473
1474 Args:
1475 view_direction: View direction as vec3 or list/tuple [x, y, z]
1476
1477 Returns:
1478 G-function value
1479
1480 Raises:
1481 RuntimeError: If the context has no geometry (or zero total leaf area),
1482 so the G-function is undefined.
1483
1484 Example:
1485 >>> from pyhelios.types import vec3
1486 >>> radiation.updateGeometry()
1487 >>> g_value = radiation.calculateGtheta(vec3(0, 0, 1))
1488 >>> print(f"G-function: {g_value}")
1489 """
1490 validate_position_like(view_direction, "view_direction", "calculateGtheta")
1491
1492 if not self._geometry_updated:
1493 logger.warning(
1494 "calculateGtheta called before updateGeometry(); updating radiation "
1495 "model geometry automatically. Call updateGeometry() explicitly after "
1496 "building the scene to avoid this."
1497 )
1498 self.updateGeometry()
1499
1500 context_ptr = self.context.getNativePtr()
1502 value = radiation_wrapper.calculateGtheta(self.radiation_model, context_ptr, view_direction)
1503
1504 if value is None or math.isnan(value):
1505 raise RuntimeError(
1506 "calculateGtheta returned an undefined (NaN) G-function. The radiation "
1507 "model has no geometry with positive leaf area for this context. Add "
1508 "primitives to the Context and ensure updateGeometry() succeeds before "
1509 "calling calculateGtheta()."
1510 )
1511 return value
1512
1513 @require_plugin('radiation', 'configure output data')
1514 def optionalOutputPrimitiveData(self, label: str):
1515 """
1516 Enable optional primitive data output.
1517
1518 Args:
1519 label: Name/label of the primitive data to output
1520
1521 Example:
1522 >>> radiation.optionalOutputPrimitiveData("temperature")
1523 """
1524 validate_band_label(label, "label", "optionalOutputPrimitiveData")
1526 radiation_wrapper.optionalOutputPrimitiveData(self.radiation_model, label)
1527 logger.debug(f"Enabled optional output for primitive data: {label}")
1528
1529 @require_plugin('radiation', 'configure boundary conditions')
1530 def enforcePeriodicBoundary(self, boundary: str):
1531 """
1532 Enforce periodic boundary conditions.
1533
1534 Periodic boundaries are useful for large-scale simulations to reduce
1535 edge effects by wrapping radiation at domain boundaries.
1536
1537 Args:
1538 boundary: Boundary specification string (e.g., "xy", "xyz", "x", "y", "z")
1539
1540 Example:
1541 >>> radiation.enforcePeriodicBoundary("xy")
1542 """
1543 if not isinstance(boundary, str) or not boundary:
1544 raise ValueError("Boundary specification must be a non-empty string")
1546 radiation_wrapper.enforcePeriodicBoundary(self.radiation_model, boundary)
1547 logger.debug(f"Enforced periodic boundary: {boundary}")
1548
1549 # Configuration methods
1550 @require_plugin('radiation', 'configure radiation simulation')
1551 @validate_scattering_depth_params
1552 def setScatteringDepth(self, label: str, depth: int):
1553 """Set scattering depth for radiation band."""
1555 radiation_wrapper.setScatteringDepth(self.radiation_model, label, depth)
1556
1557 @require_plugin('radiation', 'configure radiation simulation')
1558 @validate_min_scatter_energy_params
1559 def setMinScatterEnergy(self, label: str, energy: float):
1560 """Set minimum scatter energy for radiation band."""
1562 radiation_wrapper.setMinScatterEnergy(self.radiation_model, label, energy)
1563
1564 @require_plugin('radiation', 'configure radiation emission')
1565 def disableEmission(self, label: str):
1566 """Disable emission for radiation band."""
1567 validate_band_label(label, "label", "disableEmission")
1569 radiation_wrapper.disableEmission(self.radiation_model, label)
1570
1571 @require_plugin('radiation', 'configure radiation emission')
1572 def enableEmission(self, label: str):
1573 """Enable emission for radiation band."""
1574 validate_band_label(label, "label", "enableEmission")
1576 radiation_wrapper.enableEmission(self.radiation_model, label)
1577
1578 #=============================================================================
1579 # Camera and Image Functions (v1.3.47)
1580 #=============================================================================
1581
1582 @require_plugin('radiation', 'add radiation camera')
1583 def addRadiationCamera(self, camera_label: str, band_labels: List[str], position, lookat_or_direction,
1584 camera_properties=None, antialiasing_samples: int = 100):
1585 """
1586 Add a radiation camera to the simulation.
1587
1588 Args:
1589 camera_label: Unique label string for the camera
1590 band_labels: List of radiation band labels for the camera
1591 position: Camera position as vec3 object
1592 lookat_or_direction: Either:
1593 - Lookat point as vec3 object
1594 - SphericalCoord for viewing direction
1595 camera_properties: CameraProperties instance or None for defaults
1596 antialiasing_samples: Number of antialiasing samples (default: 100)
1597
1598 Raises:
1599 ValidationError: If parameters are invalid or have wrong types
1600 RadiationModelError: If camera creation fails
1601
1602 Example:
1603 >>> from pyhelios import vec3, CameraProperties
1604 >>> # Create camera looking at origin from above
1605 >>> camera_props = CameraProperties(camera_resolution=(1024, 1024))
1606 >>> radiation_model.addRadiationCamera("main_camera", ["red", "green", "blue"],
1607 ... position=vec3(0, 0, 5), lookat_or_direction=vec3(0, 0, 0),
1608 ... camera_properties=camera_props)
1609 """
1610 # Import here to avoid circular imports
1611 from .wrappers import URadiationModelWrapper as radiation_wrapper
1612 from .wrappers.DataTypes import SphericalCoord, vec3, make_vec3
1613 from .validation.plugins import validate_camera_label, validate_band_labels_list, validate_antialiasing_samples
1614
1615 # Validate basic parameters
1616 validated_label = validate_camera_label(camera_label, "camera_label", "addRadiationCamera")
1617 validated_bands = validate_band_labels_list(band_labels, "band_labels", "addRadiationCamera")
1618 validated_samples = validate_antialiasing_samples(antialiasing_samples, "antialiasing_samples", "addRadiationCamera")
1619
1620 # Validate position (must be vec3)
1621 if not isinstance(position, vec3):
1622 raise TypeError("position must be a vec3 object. Use vec3(x, y, z) to create one.")
1623 validated_position = position
1624
1625 # Validate lookat_or_direction (must be vec3 or SphericalCoord)
1626 if isinstance(lookat_or_direction, SphericalCoord):
1627 validated_direction = lookat_or_direction
1628 elif isinstance(lookat_or_direction, vec3):
1629 validated_direction = lookat_or_direction
1630 else:
1631 raise TypeError("lookat_or_direction must be a vec3 or SphericalCoord object. Use vec3(x, y, z) or SphericalCoord to create one.")
1632
1633 # Set up camera properties
1634 if camera_properties is None:
1635 camera_properties = CameraProperties()
1636
1637 # Call appropriate wrapper function based on direction type
1639 try:
1640 if hasattr(validated_direction, 'radius') and hasattr(validated_direction, 'elevation'):
1641 # SphericalCoord case
1642 direction_coords = validated_direction.to_list()
1643 # SphericalCoord.to_list() returns [radius, elevation, zenith, azimuth]
1644 # (4 elements). The native API expects azimuth (index 3), not zenith.
1645 if len(direction_coords) < 4:
1646 raise ValueError("SphericalCoord must expose radius, elevation, zenith, and azimuth")
1647 radius, elevation, azimuth = direction_coords[0], direction_coords[1], direction_coords[3]
1648
1649 radiation_wrapper.addRadiationCameraSpherical(
1650 self.radiation_model,
1651 validated_label,
1652 validated_bands,
1653 validated_position.x, validated_position.y, validated_position.z,
1654 radius, elevation, azimuth,
1655 camera_properties.to_array(),
1656 validated_samples,
1657 camera_properties.exposure
1658 )
1659 else:
1660 # vec3 case
1661 radiation_wrapper.addRadiationCameraVec3(
1662 self.radiation_model,
1663 validated_label,
1664 validated_bands,
1665 validated_position.x, validated_position.y, validated_position.z,
1666 validated_direction.x, validated_direction.y, validated_direction.z,
1667 camera_properties.to_array(),
1668 validated_samples,
1669 camera_properties.exposure
1670 )
1671
1672 except Exception as e:
1673 raise RadiationModelError(f"Failed to add radiation camera '{validated_label}': {e}")
1674
1675 @require_plugin('radiation', 'add SIF camera')
1676 def addSIFCamera(self, camera_label: str, emission_band_labels: List[str], position,
1677 lookat_or_direction, camera_properties=None, antialiasing_samples: int = 100):
1678 """
1679 Add a solar-induced chlorophyll fluorescence (SIF) camera.
1680
1681 Each band in ``emission_band_labels`` must already exist (added via
1682 :meth:`addRadiationBand`); those bands are flagged internally as SIF-emitting and
1683 use the Fluspect-B leaf-fluorescence kernel for emission instead of Stefan-Boltzmann.
1684 Helios auto-creates internal radiation bands covering 400-750 nm at the resolution
1685 specified by ``camera_properties.excitation_bin_width_nm``.
1686
1687 Args:
1688 camera_label: Unique label for the camera.
1689 emission_band_labels: List of pre-existing radiation band labels to drive
1690 with SIF emission.
1691 position: Camera position as a ``vec3``.
1692 lookat_or_direction: Either a ``vec3`` lookat point or a ``SphericalCoord``
1693 viewing direction.
1694 camera_properties: :class:`SIFCameraProperties` instance. If ``None`` defaults
1695 are used (10 nm excitation bins, no excitation scattering).
1696 antialiasing_samples: Antialiasing samples per pixel (>= 1, default 100).
1697
1698 Raises:
1699 RadiationModelError: If the underlying SIF camera cannot be added (e.g.,
1700 an emission band was already bound to a different excitation bin width).
1701 NotImplementedError: If running against helios-core older than v1.3.72.
1702 """
1703 from .wrappers import URadiationModelWrapper as radiation_wrapper
1704 from .wrappers.DataTypes import SphericalCoord, vec3
1705 from .validation.plugins import (
1706 validate_camera_label, validate_band_labels_list, validate_antialiasing_samples
1707 )
1708
1709 validated_label = validate_camera_label(camera_label, "camera_label", "addSIFCamera")
1710 validated_bands = validate_band_labels_list(emission_band_labels, "emission_band_labels", "addSIFCamera")
1711 validated_samples = validate_antialiasing_samples(antialiasing_samples, "antialiasing_samples", "addSIFCamera")
1712
1713 if not isinstance(position, vec3):
1714 raise TypeError("position must be a vec3 object. Use vec3(x, y, z) to create one.")
1715
1716 if not isinstance(lookat_or_direction, (vec3, SphericalCoord)):
1717 raise TypeError("lookat_or_direction must be a vec3 or SphericalCoord object.")
1718
1719 if camera_properties is None:
1720 camera_properties = SIFCameraProperties()
1721 elif not isinstance(camera_properties, SIFCameraProperties):
1722 raise TypeError(
1723 "camera_properties must be a SIFCameraProperties instance "
1724 "(use SIFCameraProperties(...) — not the plain CameraProperties)."
1725 )
1726
1728 try:
1729 if isinstance(lookat_or_direction, SphericalCoord):
1730 # SphericalCoord.to_list() is [radius, elevation, zenith, azimuth];
1731 # the C wrapper expects azimuth (index 3), not zenith.
1732 direction_coords = lookat_or_direction.to_list()
1733 if len(direction_coords) < 4:
1734 raise ValueError("SphericalCoord must expose radius, elevation, zenith, and azimuth")
1735 radius, elevation, azimuth = direction_coords[0], direction_coords[1], direction_coords[3]
1736 radiation_wrapper.addSIFCameraSpherical(
1737 self.radiation_model,
1738 validated_label,
1739 validated_bands,
1740 position.x, position.y, position.z,
1741 radius, elevation, azimuth,
1742 camera_properties.to_array(),
1743 camera_properties.excitation_bin_width_nm,
1744 camera_properties.excitation_scattering_depth,
1745 validated_samples,
1746 )
1747 else:
1748 radiation_wrapper.addSIFCameraVec3(
1749 self.radiation_model,
1750 validated_label,
1751 validated_bands,
1752 position.x, position.y, position.z,
1753 lookat_or_direction.x, lookat_or_direction.y, lookat_or_direction.z,
1754 camera_properties.to_array(),
1755 camera_properties.excitation_bin_width_nm,
1756 camera_properties.excitation_scattering_depth,
1757 validated_samples,
1758 )
1759 except Exception as e:
1760 raise RadiationModelError(f"Failed to add SIF camera '{validated_label}': {e}")
1761
1762 @require_plugin('radiation', 'check SIF camera registration')
1763 def isSIFCamera(self, camera_label: str) -> bool:
1764 """
1765 Return True if the camera was registered via :meth:`addSIFCamera` (vs. ``addRadiationCamera``).
1766 """
1767 from .wrappers import URadiationModelWrapper as radiation_wrapper
1768 if not isinstance(camera_label, str) or not camera_label.strip():
1769 raise ValueError("Camera label must be a non-empty string")
1771 return radiation_wrapper.isSIFCamera(self.radiation_model, camera_label)
1772
1773 @require_plugin('radiation', 'manage camera position')
1774 def setCameraPosition(self, camera_label: str, position):
1775 """
1776 Set camera position.
1777
1778 Allows dynamic camera repositioning during simulation, useful for
1779 time-series captures or multi-view imaging.
1780
1781 Args:
1782 camera_label: Camera label string
1783 position: Camera position as vec3 or list [x, y, z]
1784
1785 Example:
1786 >>> radiation.setCameraPosition("cam1", [0, 0, 10])
1787 >>> from pyhelios.types import vec3
1788 >>> radiation.setCameraPosition("cam1", vec3(5, 5, 10))
1789 """
1790 if not isinstance(camera_label, str) or not camera_label.strip():
1791 raise ValueError("Camera label must be a non-empty string")
1792 validate_position_like(position, "position", "setCameraPosition")
1794 radiation_wrapper.setCameraPosition(self.radiation_model, camera_label, position)
1795 logger.debug(f"Updated camera '{camera_label}' position")
1796
1797 @require_plugin('radiation', 'query camera position')
1798 def getCameraPosition(self, camera_label: str):
1799 """
1800 Get camera position.
1802 Args:
1803 camera_label: Camera label string
1804
1805 Returns:
1806 vec3 position of the camera
1807
1808 Example:
1809 >>> position = radiation.getCameraPosition("cam1")
1810 >>> print(f"Camera at: {position}")
1811 """
1812 if not isinstance(camera_label, str) or not camera_label.strip():
1813 raise ValueError("Camera label must be a non-empty string")
1815 position_list = radiation_wrapper.getCameraPosition(self.radiation_model, camera_label)
1816 from .wrappers.DataTypes import vec3
1817 return vec3(position_list[0], position_list[1], position_list[2])
1818
1819 @require_plugin('radiation', 'manage camera lookat')
1820 def setCameraLookat(self, camera_label: str, lookat):
1821 """
1822 Set camera lookat point.
1824 Args:
1825 camera_label: Camera label string
1826 lookat: Lookat point as vec3 or list [x, y, z]
1827
1828 Example:
1829 >>> radiation.setCameraLookat("cam1", [0, 0, 0])
1830 """
1831 if not isinstance(camera_label, str) or not camera_label.strip():
1832 raise ValueError("Camera label must be a non-empty string")
1833 validate_position_like(lookat, "lookat", "setCameraLookat")
1835 radiation_wrapper.setCameraLookat(self.radiation_model, camera_label, lookat)
1836 logger.debug(f"Updated camera '{camera_label}' lookat point")
1837
1838 @require_plugin('radiation', 'query camera lookat')
1839 def getCameraLookat(self, camera_label: str):
1840 """
1841 Get camera lookat point.
1843 Args:
1844 camera_label: Camera label string
1845
1846 Returns:
1847 vec3 lookat point
1848
1849 Example:
1850 >>> lookat = radiation.getCameraLookat("cam1")
1851 >>> print(f"Camera looking at: {lookat}")
1852 """
1853 if not isinstance(camera_label, str) or not camera_label.strip():
1854 raise ValueError("Camera label must be a non-empty string")
1856 lookat_list = radiation_wrapper.getCameraLookat(self.radiation_model, camera_label)
1857 from .wrappers.DataTypes import vec3
1858 return vec3(lookat_list[0], lookat_list[1], lookat_list[2])
1859
1860 @require_plugin('radiation', 'manage camera orientation')
1861 def setCameraOrientation(self, camera_label: str, direction):
1862 """
1863 Set camera orientation.
1865 Args:
1866 camera_label: Camera label string
1867 direction: View direction as vec3, SphericalCoord, or list [x, y, z]
1868
1869 Example:
1870 >>> radiation.setCameraOrientation("cam1", [0, 0, 1])
1871 >>> from pyhelios.types import SphericalCoord
1872 >>> radiation.setCameraOrientation("cam1", SphericalCoord(1.0, 45.0, 90.0))
1873 """
1874 if not isinstance(camera_label, str) or not camera_label.strip():
1875 raise ValueError("Camera label must be a non-empty string")
1876 validate_direction_like(direction, "direction", "setCameraOrientation")
1878 radiation_wrapper.setCameraOrientation(self.radiation_model, camera_label, direction)
1879 logger.debug(f"Updated camera '{camera_label}' orientation")
1880
1881 @require_plugin('radiation', 'query camera orientation')
1882 def getCameraOrientation(self, camera_label: str):
1883 """
1884 Get camera orientation.
1886 Args:
1887 camera_label: Camera label string
1888
1889 Returns:
1890 SphericalCoord orientation [radius, elevation, azimuth]
1891
1892 Example:
1893 >>> orientation = radiation.getCameraOrientation("cam1")
1894 >>> print(f"Camera orientation: {orientation}")
1895 """
1896 if not isinstance(camera_label, str) or not camera_label.strip():
1897 raise ValueError("Camera label must be a non-empty string")
1899 orientation_list = radiation_wrapper.getCameraOrientation(self.radiation_model, camera_label)
1900 from .wrappers.DataTypes import SphericalCoord
1901 return SphericalCoord(orientation_list[0], orientation_list[1], orientation_list[2])
1902
1903 @require_plugin('radiation', 'query cameras')
1904 def getAllCameraLabels(self) -> List[str]:
1905 """
1906 Get all camera labels.
1908 Returns:
1909 List of all camera label strings
1910
1911 Example:
1912 >>> cameras = radiation.getAllCameraLabels()
1913 >>> print(f"Available cameras: {cameras}")
1914 """
1916 return radiation_wrapper.getAllCameraLabels(self.radiation_model)
1917
1918 @require_plugin('radiation', 'configure camera spectral response')
1919 def setCameraSpectralResponse(self, camera_label: str, band_label: str, global_data: str):
1920 """
1921 Set camera spectral response from global data.
1922
1923 Args:
1924 camera_label: Camera label
1925 band_label: Band label
1926 global_data: Global data label for spectral response curve
1927
1928 Example:
1929 >>> radiation.setCameraSpectralResponse("cam1", "red", "sensor_red_response")
1930 """
1931 if not isinstance(camera_label, str) or not camera_label.strip():
1932 raise ValueError("Camera label must be a non-empty string")
1933 validate_band_label(band_label, "band_label", "setCameraSpectralResponse")
1934 if not isinstance(global_data, str) or not global_data.strip():
1935 raise ValueError("Global data label must be a non-empty string")
1936
1938 radiation_wrapper.setCameraSpectralResponse(self.radiation_model, camera_label, band_label, global_data)
1939 logger.debug(f"Set spectral response for camera '{camera_label}', band '{band_label}'")
1940
1941 @require_plugin('radiation', 'configure camera from library')
1942 def setCameraSpectralResponseFromLibrary(self, camera_label: str, camera_library_name: str):
1943 """
1944 Set camera spectral response from standard camera library.
1945
1946 Uses pre-defined spectral response curves for common cameras.
1947
1948 Args:
1949 camera_label: Camera label
1950 camera_library_name: Standard camera name (e.g., "iPhone13", "NikonD850", "CanonEOS5D")
1951
1952 Example:
1953 >>> radiation.setCameraSpectralResponseFromLibrary("cam1", "iPhone13")
1954 """
1955 if not isinstance(camera_label, str) or not camera_label.strip():
1956 raise ValueError("Camera label must be a non-empty string")
1957 if not isinstance(camera_library_name, str) or not camera_library_name.strip():
1958 raise ValueError("Camera library name must be a non-empty string")
1959
1961 radiation_wrapper.setCameraSpectralResponseFromLibrary(self.radiation_model, camera_label, camera_library_name)
1962 logger.debug(f"Set camera '{camera_label}' response from library: {camera_library_name}")
1963
1964 @require_plugin('radiation', 'get camera pixel data')
1965 def getCameraPixelData(self, camera_label: str, band_label: str) -> List[float]:
1966 """
1967 Get camera pixel data for specific band.
1968
1969 Retrieves raw pixel values for programmatic access and analysis.
1970
1971 Args:
1972 camera_label: Camera label
1973 band_label: Band label
1974
1975 Returns:
1976 List of pixel values
1977
1978 Example:
1979 >>> pixels = radiation.getCameraPixelData("cam1", "red")
1980 >>> print(f"Mean pixel value: {sum(pixels)/len(pixels)}")
1981 """
1982 if not isinstance(camera_label, str) or not camera_label.strip():
1983 raise ValueError("Camera label must be a non-empty string")
1984 validate_band_label(band_label, "band_label", "getCameraPixelData")
1985
1987 return radiation_wrapper.getCameraPixelData(self.radiation_model, camera_label, band_label)
1988
1989 @require_plugin('radiation', 'set camera pixel data')
1990 def setCameraPixelData(self, camera_label: str, band_label: str, pixel_data: List[float]):
1991 """
1992 Set camera pixel data for specific band.
1994 Allows programmatic modification of pixel values.
1995
1996 Args:
1997 camera_label: Camera label
1998 band_label: Band label
1999 pixel_data: List of pixel values
2000
2001 Example:
2002 >>> pixels = radiation.getCameraPixelData("cam1", "red")
2003 >>> modified_pixels = [p * 1.2 for p in pixels] # Brighten by 20%
2004 >>> radiation.setCameraPixelData("cam1", "red", modified_pixels)
2005 """
2006 if not isinstance(camera_label, str) or not camera_label.strip():
2007 raise ValueError("Camera label must be a non-empty string")
2008 validate_band_label(band_label, "band_label", "setCameraPixelData")
2009 if not isinstance(pixel_data, (list, tuple)):
2010 raise ValueError("Pixel data must be a list or tuple")
2011
2013 radiation_wrapper.setCameraPixelData(self.radiation_model, camera_label, band_label, pixel_data)
2014 logger.debug(f"Set pixel data for camera '{camera_label}', band '{band_label}': {len(pixel_data)} pixels")
2015
2016 # =========================================================================
2017 # Camera Library Functions (v1.3.58+)
2018 # =========================================================================
2019
2020 @require_plugin('radiation', 'add camera from library')
2021 def addRadiationCameraFromLibrary(self, camera_label: str, library_camera_label: str,
2022 position, lookat, antialiasing_samples: int = 1,
2023 band_labels: Optional[List[str]] = None):
2024 """
2025 Add radiation camera loading all properties from camera library.
2026
2027 Loads camera intrinsic parameters (resolution, FOV, sensor size) and spectral
2028 response data from the camera library XML file. This is the recommended way to
2029 create realistic cameras with proper spectral responses.
2030
2031 Args:
2032 camera_label: Label for the camera instance
2033 library_camera_label: Label of camera in library (e.g., "Canon_20D", "iPhone11", "NikonD700")
2034 position: Camera position as vec3 or (x, y, z) tuple
2035 lookat: Lookat point as vec3 or (x, y, z) tuple
2036 antialiasing_samples: Number of ray samples per pixel. Default: 1
2037 band_labels: Optional custom band labels. If None, uses library defaults.
2038
2039 Raises:
2040 RadiationModelError: If operation fails
2041 ValueError: If parameters are invalid
2042
2043 Note:
2044 Available cameras in plugins/radiation/camera_library/camera_library.xml include:
2045 - Canon_20D, Nikon_D700, Nikon_D50
2046 - iPhone11, iPhone12ProMAX
2047 - Additional cameras available in library
2048
2049 Example:
2050 >>> radiation.addRadiationCameraFromLibrary(
2051 ... camera_label="cam1",
2052 ... library_camera_label="iPhone11",
2053 ... position=(0, -5, 1),
2054 ... lookat=(0, 0, 0.5),
2055 ... antialiasing_samples=10
2056 ... )
2057 """
2058 validate_band_label(camera_label, "camera_label", "addRadiationCameraFromLibrary")
2059 validate_position_like(position, "position", "addRadiationCameraFromLibrary")
2060 validate_position_like(lookat, "lookat", "addRadiationCameraFromLibrary")
2061
2063 try:
2064 radiation_wrapper.addRadiationCameraFromLibrary(
2065 self.radiation_model, camera_label, library_camera_label,
2066 position, lookat, antialiasing_samples, band_labels
2068 logger.info(f"Added camera '{camera_label}' from library '{library_camera_label}'")
2069 except Exception as e:
2070 raise RadiationModelError(f"Failed to add camera from library: {e}")
2071
2072 @require_plugin('radiation', 'update camera parameters')
2073 def updateCameraParameters(self, camera_label: str, camera_properties: CameraProperties):
2074 """
2075 Update camera parameters for an existing camera.
2076
2077 Allows modification of camera properties after creation while preserving
2078 position, lookat direction, and spectral band configuration.
2079
2080 Args:
2081 camera_label: Label for the camera to update
2082 camera_properties: CameraProperties instance with new parameters
2083
2084 Raises:
2085 RadiationModelError: If operation fails or camera doesn't exist
2086 ValueError: If parameters are invalid
2087
2088 Note:
2089 FOV_aspect_ratio is automatically recalculated from camera_resolution.
2090 Camera position and lookat are preserved.
2091
2092 Example:
2093 >>> props = CameraProperties(
2094 ... camera_resolution=(1920, 1080),
2095 ... HFOV=35.0,
2096 ... lens_focal_length=0.085 # 85mm lens
2097 ... )
2098 >>> radiation.updateCameraParameters("cam1", props)
2099 """
2100 validate_band_label(camera_label, "camera_label", "updateCameraParameters")
2101
2102 if not isinstance(camera_properties, CameraProperties):
2103 raise ValueError("camera_properties must be a CameraProperties instance")
2104
2106 try:
2107 radiation_wrapper.updateCameraParameters(self.radiation_model, camera_label, camera_properties)
2108 logger.debug(f"Updated parameters for camera '{camera_label}'")
2109 except Exception as e:
2110 raise RadiationModelError(f"Failed to update camera parameters: {e}")
2112 @require_plugin('radiation', 'enable camera metadata')
2113 def enableCameraMetadata(self, camera_labels):
2114 """
2115 Enable automatic JSON metadata file writing for camera(s).
2116
2117 When enabled, writeCameraImage() automatically creates a JSON metadata file
2118 alongside the image containing comprehensive camera and scene information.
2119
2120 Args:
2121 camera_labels: Single camera label (str) or list of camera labels (List[str])
2122
2123 Raises:
2124 RadiationModelError: If operation fails
2125 ValueError: If parameters are invalid
2126
2127 Note:
2128 Metadata includes:
2129 - Camera properties (model, lens, sensor specs)
2130 - Geographic location (latitude, longitude)
2131 - Acquisition settings (date, time, exposure, white balance)
2132 - Agronomic data (plant species, heights, phenology stages)
2133
2134 Example:
2135 >>> # Enable for single camera
2136 >>> radiation.enableCameraMetadata("cam1")
2137 >>>
2138 >>> # Enable for multiple cameras
2139 >>> radiation.enableCameraMetadata(["cam1", "cam2", "cam3"])
2140 """
2142 try:
2143 radiation_wrapper.enableCameraMetadata(self.radiation_model, camera_labels)
2144 if isinstance(camera_labels, str):
2145 logger.info(f"Enabled metadata for camera '{camera_labels}'")
2146 else:
2147 logger.info(f"Enabled metadata for {len(camera_labels)} cameras")
2148 except Exception as e:
2149 raise RadiationModelError(f"Failed to enable camera metadata: {e}")
2150
2151 @require_plugin('radiation', 'write camera images')
2152 def writeCameraImage(self, camera: str, bands: List[str], imagefile_base: str,
2153 image_path: str = "./", frame: int = -1,
2154 flux_to_pixel_conversion: float = 1.0) -> str:
2155 """
2156 Write camera image to file and return output filename.
2157
2158 Args:
2159 camera: Camera label
2160 bands: List of band labels to include in the image
2161 imagefile_base: Base filename for output
2162 image_path: Output directory path (default: current directory)
2163 frame: Frame number to write (-1 for all frames)
2164 flux_to_pixel_conversion: Conversion factor from flux to pixel values
2165
2166 Returns:
2167 Output filename string
2168
2169 Raises:
2170 RadiationModelError: If camera image writing fails
2171 TypeError: If parameters have incorrect types
2172 """
2173 # Validate inputs
2174 if not isinstance(camera, str) or not camera.strip():
2175 raise TypeError("Camera label must be a non-empty string")
2176 if not isinstance(bands, list) or not bands:
2177 raise TypeError("Bands must be a non-empty list of strings")
2178 if not all(isinstance(band, str) and band.strip() for band in bands):
2179 raise TypeError("All band labels must be non-empty strings")
2180 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
2181 raise TypeError("Image file base must be a non-empty string")
2182 if not isinstance(image_path, str):
2183 raise TypeError("Image path must be a string")
2184 if not isinstance(frame, int):
2185 raise TypeError("Frame must be an integer")
2186 if not isinstance(flux_to_pixel_conversion, (int, float)) or flux_to_pixel_conversion <= 0:
2187 raise TypeError("Flux to pixel conversion must be a positive number")
2188
2190 self._check_camera_has_pixel_data(camera, bands, "write camera image")
2191 filename = radiation_wrapper.writeCameraImage(
2192 self.radiation_model, camera, bands, imagefile_base,
2193 image_path, frame, flux_to_pixel_conversion)
2194
2195 logger.info(f"Camera image written to: {filename}")
2196 return filename
2197
2198 @require_plugin('radiation', 'write normalized camera images')
2199 def writeNormCameraImage(self, camera: str, bands: List[str], imagefile_base: str,
2200 image_path: str = "./", frame: int = -1) -> str:
2201 """
2202 Write normalized camera image to file and return output filename.
2203
2204 Args:
2205 camera: Camera label
2206 bands: List of band labels to include in the image
2207 imagefile_base: Base filename for output
2208 image_path: Output directory path (default: current directory)
2209 frame: Frame number to write (-1 for all frames)
2210
2211 Returns:
2212 Output filename string
2213
2214 Raises:
2215 RadiationModelError: If normalized camera image writing fails
2216 TypeError: If parameters have incorrect types
2217 """
2218 # Validate inputs
2219 if not isinstance(camera, str) or not camera.strip():
2220 raise TypeError("Camera label must be a non-empty string")
2221 if not isinstance(bands, list) or not bands:
2222 raise TypeError("Bands must be a non-empty list of strings")
2223 if not all(isinstance(band, str) and band.strip() for band in bands):
2224 raise TypeError("All band labels must be non-empty strings")
2225 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
2226 raise TypeError("Image file base must be a non-empty string")
2227 if not isinstance(image_path, str):
2228 raise TypeError("Image path must be a string")
2229 if not isinstance(frame, int):
2230 raise TypeError("Frame must be an integer")
2231
2233 self._check_camera_has_pixel_data(camera, bands, "write normalized camera image")
2234 filename = radiation_wrapper.writeNormCameraImage(
2235 self.radiation_model, camera, bands, imagefile_base, image_path, frame)
2236
2237 logger.info(f"Normalized camera image written to: {filename}")
2238 return filename
2239
2240 @require_plugin('radiation', 'write camera image data')
2241 def writeCameraImageData(self, camera: str, band: str, imagefile_base: str,
2242 image_path: str = "./", frame: int = -1):
2243 """
2244 Write camera image data to file (ASCII format).
2245
2246 Args:
2247 camera: Camera label
2248 band: Band label
2249 imagefile_base: Base filename for output
2250 image_path: Output directory path (default: current directory)
2251 frame: Frame number to write (-1 for all frames)
2252
2253 Raises:
2254 RadiationModelError: If camera image data writing fails
2255 TypeError: If parameters have incorrect types
2256 """
2257 # Validate inputs
2258 if not isinstance(camera, str) or not camera.strip():
2259 raise TypeError("Camera label must be a non-empty string")
2260 if not isinstance(band, str) or not band.strip():
2261 raise TypeError("Band label must be a non-empty string")
2262 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
2263 raise TypeError("Image file base must be a non-empty string")
2264 if not isinstance(image_path, str):
2265 raise TypeError("Image path must be a string")
2266 if not isinstance(frame, int):
2267 raise TypeError("Frame must be an integer")
2268
2270 radiation_wrapper.writeCameraImageData(
2271 self.radiation_model, camera, band, imagefile_base, image_path, frame)
2272
2273 logger.info(f"Camera image data written for camera {camera}, band {band}")
2274
2275 @require_plugin('radiation', 'write primitive data label map')
2276 def writePrimitiveDataLabelMap(self, camera: str, primitive_data_label: str, imagefile_base: str,
2277 image_path: str = "./", frame: int = -1, padvalue: float = float('nan')):
2278 """
2279 Write a per-pixel primitive-data label map for a camera to a text file.
2280
2281 For each camera pixel, writes the value of ``primitive_data_label`` on the primitive
2282 seen at that pixel. Pixels that hit no geometry (or a primitive lacking the data) are
2283 written as ``padvalue`` (NaN by default). The primitive data must be of type
2284 float, double, uint, or int. The radiation model must have been run
2285 (``updateGeometry`` + ``runBand``) so that per-pixel primitive labels exist.
2286
2287 The output file is written row-by-row (one line per image row), so it loads directly
2288 into a 2D ``(height, width)`` array. See :meth:`getPrimitiveDataLabelMap` for a
2289 convenience that returns a NumPy array instead of a file on disk.
2290
2291 Output filename: ``{camera}_{imagefile_base}.txt`` when ``frame < 0`` (default), or
2292 ``{camera}_{imagefile_base}_{frame:05d}.txt`` when ``frame >= 0``.
2293
2294 Args:
2295 camera: Camera label
2296 primitive_data_label: Primitive data label to map (float/double/uint/int)
2297 imagefile_base: Base filename for output
2298 image_path: Output directory path (default: current directory)
2299 frame: Frame number to write (-1 to omit the frame suffix)
2300 padvalue: Value written for empty/background pixels (default: NaN)
2301
2302 Raises:
2303 RadiationModelError: If the label map writing fails
2304 TypeError: If parameters have incorrect types
2305
2306 Example:
2307 >>> radiation.writePrimitiveDataLabelMap(
2308 ... camera="main_cam", primitive_data_label="leaf_id",
2309 ... imagefile_base="leaf_labels", image_path="./output")
2310 """
2311 # Validate inputs
2312 if not isinstance(camera, str) or not camera.strip():
2313 raise TypeError("Camera label must be a non-empty string")
2314 if not isinstance(primitive_data_label, str) or not primitive_data_label.strip():
2315 raise TypeError("Primitive data label must be a non-empty string")
2316 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
2317 raise TypeError("Image file base must be a non-empty string")
2318 if not isinstance(image_path, str):
2319 raise TypeError("Image path must be a string")
2320 if not isinstance(frame, int):
2321 raise TypeError("Frame must be an integer")
2322 if not isinstance(padvalue, (int, float)) or isinstance(padvalue, bool):
2323 raise TypeError("Pad value must be a numeric type")
2324
2326 radiation_wrapper.writePrimitiveDataLabelMap(
2327 self.radiation_model, camera, primitive_data_label, imagefile_base,
2328 image_path, frame, float(padvalue))
2329
2330 logger.info(f"Primitive data label map written for camera {camera}, label {primitive_data_label}")
2331
2332 @require_plugin('radiation', 'write object data label map')
2333 def writeObjectDataLabelMap(self, camera: str, object_data_label: str, imagefile_base: str,
2334 image_path: str = "./", frame: int = -1, padvalue: float = float('nan')):
2335 """
2336 Write a per-pixel object-data label map for a camera to a text file.
2337
2338 Identical to :meth:`writePrimitiveDataLabelMap` but maps the value of an object-data
2339 label (compound-object data) rather than primitive data. The object data must be of
2340 type float, double, uint, or int. The radiation model must have been run
2341 (``updateGeometry`` + ``runBand``) so that per-pixel labels exist.
2342
2343 Output filename: ``{camera}_{imagefile_base}.txt`` when ``frame < 0`` (default), or
2344 ``{camera}_{imagefile_base}_{frame:05d}.txt`` when ``frame >= 0``.
2345
2346 Args:
2347 camera: Camera label
2348 object_data_label: Object data label to map (float/double/uint/int)
2349 imagefile_base: Base filename for output
2350 image_path: Output directory path (default: current directory)
2351 frame: Frame number to write (-1 to omit the frame suffix)
2352 padvalue: Value written for empty/background pixels (default: NaN)
2353
2354 Raises:
2355 RadiationModelError: If the label map writing fails
2356 TypeError: If parameters have incorrect types
2357 """
2358 # Validate inputs
2359 if not isinstance(camera, str) or not camera.strip():
2360 raise TypeError("Camera label must be a non-empty string")
2361 if not isinstance(object_data_label, str) or not object_data_label.strip():
2362 raise TypeError("Object data label must be a non-empty string")
2363 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
2364 raise TypeError("Image file base must be a non-empty string")
2365 if not isinstance(image_path, str):
2366 raise TypeError("Image path must be a string")
2367 if not isinstance(frame, int):
2368 raise TypeError("Frame must be an integer")
2369 if not isinstance(padvalue, (int, float)) or isinstance(padvalue, bool):
2370 raise TypeError("Pad value must be a numeric type")
2371
2373 radiation_wrapper.writeObjectDataLabelMap(
2374 self.radiation_model, camera, object_data_label, imagefile_base,
2375 image_path, frame, float(padvalue))
2376
2377 logger.info(f"Object data label map written for camera {camera}, label {object_data_label}")
2378
2379 @require_plugin('radiation', 'read primitive data label map')
2380 def getPrimitiveDataLabelMap(self, camera: str, primitive_data_label: str,
2381 padvalue: float = float('nan')) -> 'np.ndarray':
2382 """
2383 Return a per-pixel primitive-data label map for a camera as a NumPy array.
2384
2385 Convenience wrapper around :meth:`writePrimitiveDataLabelMap` for the common use case
2386 of per-pixel masking in Python: the label map is written to a temporary file, loaded
2387 with ``numpy.loadtxt``, and returned as a 2D array. The file does not persist.
2388
2389 Args:
2390 camera: Camera label
2391 primitive_data_label: Primitive data label to map (float/double/uint/int)
2392 padvalue: Value used for empty/background pixels (default: NaN)
2393
2394 Returns:
2395 2D NumPy array of shape ``(height, width)`` (row-major) holding the primitive-data
2396 value at each pixel, with ``padvalue`` (NaN by default) where no labelled geometry
2397 was seen.
2398
2399 Raises:
2400 RadiationModelError: If the label map generation fails
2401 TypeError: If parameters have incorrect types
2402
2403 Example:
2404 >>> labels = radiation.getPrimitiveDataLabelMap("main_cam", "leaf_id")
2405 >>> mask = labels == 3 # per-pixel mask for primitive label 3
2406 >>> background = np.isnan(labels)
2407 """
2408 with tempfile.TemporaryDirectory() as tmpdir:
2409 imagefile_base = "labelmap"
2411 camera, primitive_data_label, imagefile_base,
2412 image_path=os.path.join(tmpdir, ""), frame=-1, padvalue=padvalue)
2413 # frame < 0 => filename has no frame suffix
2414 filepath = os.path.join(tmpdir, f"{camera}_{imagefile_base}.txt")
2415 labels = np.loadtxt(filepath)
2416
2417 # np.loadtxt collapses a single-row file to 1D; keep a consistent 2D shape.
2418 if labels.ndim == 1:
2419 labels = labels.reshape(1, -1)
2420 return labels
2421
2422 @require_plugin('radiation', 'read object data label map')
2423 def getObjectDataLabelMap(self, camera: str, object_data_label: str,
2424 padvalue: float = float('nan')) -> 'np.ndarray':
2425 """
2426 Return a per-pixel object-data label map for a camera as a NumPy array.
2427
2428 Convenience wrapper around :meth:`writeObjectDataLabelMap`; see
2429 :meth:`getPrimitiveDataLabelMap` for behaviour. The label map is written to a temporary
2430 file, loaded with ``numpy.loadtxt``, and returned as a 2D ``(height, width)`` array with
2431 ``padvalue`` (NaN by default) for background pixels. The file does not persist.
2432
2433 Args:
2434 camera: Camera label
2435 object_data_label: Object data label to map (float/double/uint/int)
2436 padvalue: Value used for empty/background pixels (default: NaN)
2437
2438 Returns:
2439 2D NumPy array of shape ``(height, width)`` (row-major).
2440
2441 Raises:
2442 RadiationModelError: If the label map generation fails
2443 TypeError: If parameters have incorrect types
2444 """
2445 with tempfile.TemporaryDirectory() as tmpdir:
2446 imagefile_base = "labelmap"
2448 camera, object_data_label, imagefile_base,
2449 image_path=os.path.join(tmpdir, ""), frame=-1, padvalue=padvalue)
2450 filepath = os.path.join(tmpdir, f"{camera}_{imagefile_base}.txt")
2451 labels = np.loadtxt(filepath)
2452
2453 if labels.ndim == 1:
2454 labels = labels.reshape(1, -1)
2455 return labels
2456
2457 @require_plugin('radiation', 'write image bounding boxes')
2458 def writeImageBoundingBoxes(self, camera_label: str,
2459 primitive_data_labels=None, object_data_labels=None,
2460 object_class_ids=None, image_file: str = "",
2461 classes_txt_file: str = "classes.txt",
2462 image_path: str = "./"):
2463 """
2464 Write image bounding boxes for object detection training.
2465
2466 Supports both single and multiple data labels. Either provide primitive_data_labels
2467 or object_data_labels, not both.
2468
2469 Args:
2470 camera_label: Camera label
2471 primitive_data_labels: Single primitive data label (str) or list of primitive data labels
2472 object_data_labels: Single object data label (str) or list of object data labels
2473 object_class_ids: Single class ID (int) or list of class IDs (must match data labels)
2474 image_file: Image filename
2475 classes_txt_file: Classes definition file (default: "classes.txt")
2476 image_path: Image output path (default: current directory)
2477
2478 Raises:
2479 RadiationModelError: If bounding box writing fails
2480 TypeError: If parameters have incorrect types
2481 ValueError: If both primitive and object data labels are provided, or neither
2482 """
2483 # Validate exclusive parameter usage
2484 if primitive_data_labels is not None and object_data_labels is not None:
2485 raise ValueError("Cannot specify both primitive_data_labels and object_data_labels")
2486 if primitive_data_labels is None and object_data_labels is None:
2487 raise ValueError("Must specify either primitive_data_labels or object_data_labels")
2488
2489 # Validate common parameters
2490 if not isinstance(camera_label, str) or not camera_label.strip():
2491 raise TypeError("Camera label must be a non-empty string")
2492 if not isinstance(image_file, str) or not image_file.strip():
2493 raise TypeError("Image file must be a non-empty string")
2494 if not isinstance(classes_txt_file, str):
2495 raise TypeError("Classes txt file must be a string")
2496 if not isinstance(image_path, str):
2497 raise TypeError("Image path must be a string")
2498
2499 # Handle primitive data labels
2500 if primitive_data_labels is not None:
2501 if isinstance(primitive_data_labels, str):
2502 # Single label
2503 if not isinstance(object_class_ids, int):
2504 raise TypeError("For single primitive data label, object_class_ids must be an integer")
2506 radiation_wrapper.writeImageBoundingBoxes(
2507 self.radiation_model, camera_label, primitive_data_labels,
2508 object_class_ids, image_file, classes_txt_file, image_path)
2509 logger.info(f"Image bounding boxes written for primitive data: {primitive_data_labels}")
2510
2511 elif isinstance(primitive_data_labels, list):
2512 # Multiple labels
2513 if not isinstance(object_class_ids, list):
2514 raise TypeError("For multiple primitive data labels, object_class_ids must be a list")
2515 if len(primitive_data_labels) != len(object_class_ids):
2516 raise ValueError("primitive_data_labels and object_class_ids must have the same length")
2517 if not all(isinstance(lbl, str) and lbl.strip() for lbl in primitive_data_labels):
2518 raise TypeError("All primitive data labels must be non-empty strings")
2519 if not all(isinstance(cid, int) for cid in object_class_ids):
2520 raise TypeError("All object class IDs must be integers")
2521
2523 radiation_wrapper.writeImageBoundingBoxesVector(
2524 self.radiation_model, camera_label, primitive_data_labels,
2525 object_class_ids, image_file, classes_txt_file, image_path)
2526 logger.info(f"Image bounding boxes written for {len(primitive_data_labels)} primitive data labels")
2527 else:
2528 raise TypeError("primitive_data_labels must be a string or list of strings")
2529
2530 # Handle object data labels
2531 elif object_data_labels is not None:
2532 if isinstance(object_data_labels, str):
2533 # Single label
2534 if not isinstance(object_class_ids, int):
2535 raise TypeError("For single object data label, object_class_ids must be an integer")
2537 radiation_wrapper.writeImageBoundingBoxes_ObjectData(
2538 self.radiation_model, camera_label, object_data_labels,
2539 object_class_ids, image_file, classes_txt_file, image_path)
2540 logger.info(f"Image bounding boxes written for object data: {object_data_labels}")
2541
2542 elif isinstance(object_data_labels, list):
2543 # Multiple labels
2544 if not isinstance(object_class_ids, list):
2545 raise TypeError("For multiple object data labels, object_class_ids must be a list")
2546 if len(object_data_labels) != len(object_class_ids):
2547 raise ValueError("object_data_labels and object_class_ids must have the same length")
2548 if not all(isinstance(lbl, str) and lbl.strip() for lbl in object_data_labels):
2549 raise TypeError("All object data labels must be non-empty strings")
2550 if not all(isinstance(cid, int) for cid in object_class_ids):
2551 raise TypeError("All object class IDs must be integers")
2552
2554 radiation_wrapper.writeImageBoundingBoxes_ObjectDataVector(
2555 self.radiation_model, camera_label, object_data_labels,
2556 object_class_ids, image_file, classes_txt_file, image_path)
2557 logger.info(f"Image bounding boxes written for {len(object_data_labels)} object data labels")
2558 else:
2559 raise TypeError("object_data_labels must be a string or list of strings")
2560
2561 @require_plugin('radiation', 'write image segmentation masks')
2562 def writeImageSegmentationMasks(self, camera_label: str,
2563 primitive_data_labels=None, object_data_labels=None,
2564 object_class_ids=None, json_filename: str = "",
2565 image_file: str = "", append_file: bool = False):
2566 """
2567 Write image segmentation masks in COCO JSON format.
2568
2569 Supports both single and multiple data labels. Either provide primitive_data_labels
2570 or object_data_labels, not both.
2571
2572 Args:
2573 camera_label: Camera label
2574 primitive_data_labels: Single primitive data label (str) or list of primitive data labels
2575 object_data_labels: Single object data label (str) or list of object data labels
2576 object_class_ids: Single class ID (int) or list of class IDs (must match data labels)
2577 json_filename: JSON output filename
2578 image_file: Image filename
2579 append_file: Whether to append to existing JSON file
2580
2581 Raises:
2582 RadiationModelError: If segmentation mask writing fails
2583 TypeError: If parameters have incorrect types
2584 ValueError: If both primitive and object data labels are provided, or neither
2585 """
2586 # Validate exclusive parameter usage
2587 if primitive_data_labels is not None and object_data_labels is not None:
2588 raise ValueError("Cannot specify both primitive_data_labels and object_data_labels")
2589 if primitive_data_labels is None and object_data_labels is None:
2590 raise ValueError("Must specify either primitive_data_labels or object_data_labels")
2591
2592 # Validate common parameters
2593 if not isinstance(camera_label, str) or not camera_label.strip():
2594 raise TypeError("Camera label must be a non-empty string")
2595 if not isinstance(json_filename, str) or not json_filename.strip():
2596 raise TypeError("JSON filename must be a non-empty string")
2597 if not isinstance(image_file, str) or not image_file.strip():
2598 raise TypeError("Image file must be a non-empty string")
2599 if not isinstance(append_file, bool):
2600 raise TypeError("append_file must be a boolean")
2601
2602 # Handle primitive data labels
2603 if primitive_data_labels is not None:
2604 if isinstance(primitive_data_labels, str):
2605 # Single label
2606 if not isinstance(object_class_ids, int):
2607 raise TypeError("For single primitive data label, object_class_ids must be an integer")
2609 radiation_wrapper.writeImageSegmentationMasks(
2610 self.radiation_model, camera_label, primitive_data_labels,
2611 object_class_ids, json_filename, image_file, append_file)
2612 logger.info(f"Image segmentation masks written for primitive data: {primitive_data_labels}")
2613
2614 elif isinstance(primitive_data_labels, list):
2615 # Multiple labels
2616 if not isinstance(object_class_ids, list):
2617 raise TypeError("For multiple primitive data labels, object_class_ids must be a list")
2618 if len(primitive_data_labels) != len(object_class_ids):
2619 raise ValueError("primitive_data_labels and object_class_ids must have the same length")
2620 if not all(isinstance(lbl, str) and lbl.strip() for lbl in primitive_data_labels):
2621 raise TypeError("All primitive data labels must be non-empty strings")
2622 if not all(isinstance(cid, int) for cid in object_class_ids):
2623 raise TypeError("All object class IDs must be integers")
2624
2626 radiation_wrapper.writeImageSegmentationMasksVector(
2627 self.radiation_model, camera_label, primitive_data_labels,
2628 object_class_ids, json_filename, image_file, append_file)
2629 logger.info(f"Image segmentation masks written for {len(primitive_data_labels)} primitive data labels")
2630 else:
2631 raise TypeError("primitive_data_labels must be a string or list of strings")
2632
2633 # Handle object data labels
2634 elif object_data_labels is not None:
2635 if isinstance(object_data_labels, str):
2636 # Single label
2637 if not isinstance(object_class_ids, int):
2638 raise TypeError("For single object data label, object_class_ids must be an integer")
2640 radiation_wrapper.writeImageSegmentationMasks_ObjectData(
2641 self.radiation_model, camera_label, object_data_labels,
2642 object_class_ids, json_filename, image_file, append_file)
2643 logger.info(f"Image segmentation masks written for object data: {object_data_labels}")
2644
2645 elif isinstance(object_data_labels, list):
2646 # Multiple labels
2647 if not isinstance(object_class_ids, list):
2648 raise TypeError("For multiple object data labels, object_class_ids must be a list")
2649 if len(object_data_labels) != len(object_class_ids):
2650 raise ValueError("object_data_labels and object_class_ids must have the same length")
2651 if not all(isinstance(lbl, str) and lbl.strip() for lbl in object_data_labels):
2652 raise TypeError("All object data labels must be non-empty strings")
2653 if not all(isinstance(cid, int) for cid in object_class_ids):
2654 raise TypeError("All object class IDs must be integers")
2655
2657 radiation_wrapper.writeImageSegmentationMasks_ObjectDataVector(
2658 self.radiation_model, camera_label, object_data_labels,
2659 object_class_ids, json_filename, image_file, append_file)
2660 logger.info(f"Image segmentation masks written for {len(object_data_labels)} object data labels")
2661 else:
2662 raise TypeError("object_data_labels must be a string or list of strings")
2663
2664 @require_plugin('radiation', 'auto-calibrate camera image')
2665 def autoCalibrateCameraImage(self, camera_label: str, red_band_label: str,
2666 green_band_label: str, blue_band_label: str,
2667 output_file_path: str, print_quality_report: bool = False,
2668 algorithm: str = "MATRIX_3X3_AUTO",
2669 ccm_export_file_path: str = "") -> str:
2670 """
2671 Auto-calibrate camera image with color correction and return output filename.
2672
2673 Args:
2674 camera_label: Camera label
2675 red_band_label: Red band label
2676 green_band_label: Green band label
2677 blue_band_label: Blue band label
2678 output_file_path: Output file path
2679 print_quality_report: Whether to print quality report
2680 algorithm: Color correction algorithm ("DIAGONAL_ONLY", "MATRIX_3X3_AUTO", "MATRIX_3X3_FORCE")
2681 ccm_export_file_path: Path to export color correction matrix (optional)
2682
2683 Returns:
2684 Output filename string
2685
2686 Raises:
2687 RadiationModelError: If auto-calibration fails
2688 TypeError: If parameters have incorrect types
2689 ValueError: If algorithm is not valid
2690 """
2691 # Validate inputs
2692 if not isinstance(camera_label, str) or not camera_label.strip():
2693 raise TypeError("Camera label must be a non-empty string")
2694 if not isinstance(red_band_label, str) or not red_band_label.strip():
2695 raise TypeError("Red band label must be a non-empty string")
2696 if not isinstance(green_band_label, str) or not green_band_label.strip():
2697 raise TypeError("Green band label must be a non-empty string")
2698 if not isinstance(blue_band_label, str) or not blue_band_label.strip():
2699 raise TypeError("Blue band label must be a non-empty string")
2700 if not isinstance(output_file_path, str) or not output_file_path.strip():
2701 raise TypeError("Output file path must be a non-empty string")
2702 if not isinstance(print_quality_report, bool):
2703 raise TypeError("print_quality_report must be a boolean")
2704 if not isinstance(ccm_export_file_path, str):
2705 raise TypeError("ccm_export_file_path must be a string")
2706
2707 # Map algorithm string to integer (using MATRIX_3X3_AUTO = 1 as default)
2708 algorithm_map = {
2709 "DIAGONAL_ONLY": 0,
2710 "MATRIX_3X3_AUTO": 1,
2711 "MATRIX_3X3_FORCE": 2
2712 }
2713
2714 if algorithm not in algorithm_map:
2715 raise ValueError(f"Invalid algorithm: {algorithm}. Must be one of: {list(algorithm_map.keys())}")
2716
2717 algorithm_int = algorithm_map[algorithm]
2718
2720 filename = radiation_wrapper.autoCalibrateCameraImage(
2721 self.radiation_model, camera_label, red_band_label, green_band_label,
2722 blue_band_label, output_file_path, print_quality_report,
2723 algorithm_int, ccm_export_file_path)
2724
2725 logger.info(f"Auto-calibrated camera image written to: {filename}")
2726 return filename
2727
2728 def getPluginInfo(self) -> dict:
2729 """Get information about the radiation plugin."""
2730 registry = get_plugin_registry()
2731 return registry.get_plugin_capabilities('radiation')
2732
2733 # =========================================================================
2734 # EXR Image Export (v1.3.66+)
2735 # =========================================================================
2736
2737 def writeCameraImageDataEXR(self, camera: str, band, imagefile_base: str,
2738 image_path: str = "./", frame: int = -1):
2739 """
2740 Write camera pixel data to an EXR file with lossless float compression.
2742 Preserves full floating-point precision unlike JPEG/PNG exports.
2743
2744 Args:
2745 camera: Camera label
2746 band: Band label (str) for single-band, or list of band labels for multi-band
2747 imagefile_base: Base filename for output
2748 image_path: Output directory path (default: current directory)
2749 frame: Frame number to append to filename (-1 to omit)
2750
2751 Raises:
2752 RadiationModelError: If writing fails
2753 TypeError: If parameters have incorrect types
2754 """
2755 if not isinstance(camera, str) or not camera.strip():
2756 raise TypeError("Camera label must be a non-empty string")
2757 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
2758 raise TypeError("Image file base must be a non-empty string")
2759 if not isinstance(image_path, str):
2760 raise TypeError("Image path must be a string")
2761 if not isinstance(frame, int):
2762 raise TypeError("Frame must be an integer")
2763
2764 if isinstance(band, str):
2765 if not band.strip():
2766 raise TypeError("Band label must be a non-empty string")
2767 elif isinstance(band, (list, tuple)):
2768 if not band:
2769 raise ValueError("Band list cannot be empty")
2770 for b in band:
2771 if not isinstance(b, str) or not b.strip():
2772 raise TypeError("Each band label must be a non-empty string")
2773 else:
2774 raise TypeError("band must be a string or list of strings")
2775
2777 radiation_wrapper.writeCameraImageDataEXR(
2778 self.radiation_model, camera, band, imagefile_base, image_path, frame)
2779
2780 def writeDepthImageData(self, camera_label: str, imagefile_base: str,
2781 image_path: str = "./", frame: int = -1):
2782 """
2783 Write depth image data to an ASCII text file.
2784
2785 Args:
2786 camera_label: Camera label
2787 imagefile_base: Base filename for output
2788 image_path: Output directory path (default: current directory)
2789 frame: Frame number to append to filename (-1 to omit)
2790
2791 Raises:
2792 RadiationModelError: If writing fails
2793 TypeError: If parameters have incorrect types
2794 """
2795 if not isinstance(camera_label, str) or not camera_label.strip():
2796 raise TypeError("Camera label must be a non-empty string")
2797 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
2798 raise TypeError("Image file base must be a non-empty string")
2799 if not isinstance(image_path, str):
2800 raise TypeError("Image path must be a string")
2801 if not isinstance(frame, int):
2802 raise TypeError("Frame must be an integer")
2803
2805 radiation_wrapper.writeDepthImageData(
2806 self.radiation_model, camera_label, imagefile_base, image_path, frame)
2807
2808 def writeDepthImageDataEXR(self, camera_label: str, imagefile_base: str,
2809 image_path: str = "./", frame: int = -1):
2810 """
2811 Write depth image data to an EXR file with lossless float compression.
2812
2813 Preserves full floating-point depth precision unlike ASCII or JPEG exports.
2814
2815 Args:
2816 camera_label: Camera label
2817 imagefile_base: Base filename for output
2818 image_path: Output directory path (default: current directory)
2819 frame: Frame number to append to filename (-1 to omit)
2820
2821 Raises:
2822 RadiationModelError: If writing fails
2823 TypeError: If parameters have incorrect types
2824 """
2825 if not isinstance(camera_label, str) or not camera_label.strip():
2826 raise TypeError("Camera label must be a non-empty string")
2827 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
2828 raise TypeError("Image file base must be a non-empty string")
2829 if not isinstance(image_path, str):
2830 raise TypeError("Image path must be a string")
2831 if not isinstance(frame, int):
2832 raise TypeError("Frame must be an integer")
2833
2835 radiation_wrapper.writeDepthImageDataEXR(
2836 self.radiation_model, camera_label, imagefile_base, image_path, frame)
2837
2838 def writeNormDepthImage(self, camera_label: str, imagefile_base: str, max_depth: float,
2839 image_path: str = "./", frame: int = -1):
2840 """
2841 Write normalized depth image as grayscale JPEG.
2842
2843 Depth values are normalized to the range [0, max_depth] for visualization.
2844
2845 Args:
2846 camera_label: Camera label
2847 imagefile_base: Base filename for output
2848 max_depth: Maximum depth value for normalization (e.g., sky depth)
2849 image_path: Output directory path (default: current directory)
2850 frame: Frame number to append to filename (-1 to omit)
2851
2852 Raises:
2853 RadiationModelError: If writing fails
2854 TypeError: If parameters have incorrect types
2855 ValueError: If max_depth is not positive
2856 """
2857 if not isinstance(camera_label, str) or not camera_label.strip():
2858 raise TypeError("Camera label must be a non-empty string")
2859 if not isinstance(imagefile_base, str) or not imagefile_base.strip():
2860 raise TypeError("Image file base must be a non-empty string")
2861 if not isinstance(max_depth, (int, float)):
2862 raise TypeError("max_depth must be a number")
2863 if max_depth <= 0:
2864 raise ValueError("max_depth must be positive")
2865 if not isinstance(image_path, str):
2866 raise TypeError("Image path must be a string")
2867 if not isinstance(frame, int):
2868 raise TypeError("Frame must be an integer")
2869
2871 radiation_wrapper.writeNormDepthImage(
2872 self.radiation_model, camera_label, imagefile_base, float(max_depth), image_path, frame)
2873
2874 # =========================================================================
2875 # Backend Query (v1.3.67+)
2876 # =========================================================================
2877
2878 def getBackendName(self) -> str:
2879 """
2880 Get the name of the active ray tracing backend.
2881
2882 Returns:
2883 Backend name string (e.g., "OptiX 8.1", "Vulkan Compute")
2884 """
2886 return radiation_wrapper.getBackendName(self.radiation_model)
2887
2888 @staticmethod
2889 def probeAnyGPUBackend() -> bool:
2890 """
2891 Probe whether any compiled-in GPU backend is available on this system.
2892
2893 Probes backends in priority order (OptiX 8 -> OptiX 6 -> Vulkan) without
2894 constructing a full backend. Useful for checking GPU availability before
2895 creating a RadiationModel.
2897 Returns:
2898 True if at least one GPU backend is available
2899 """
2900 return radiation_wrapper.probeAnyGPUBackend()
__init__(self, date="", time="", UTC_offset=0.0, camera_height_m=0.0, camera_angle_deg=0.0, light_source="sunlight")
Agronomic properties derived from plant architecture data.
__init__(self, plant_species=None, plant_count=None, plant_height_m=None, plant_age_days=None, plant_stage=None, leaf_area_m2=None, weed_pressure="")
__init__(self, height=512, width=512, channels=3, type="rgb", focal_length=50.0, aperture="f/2.8", sensor_width=35.0, sensor_height=24.0, model="generic", lens_make="", lens_model="", lens_specification="", exposure="auto", shutter_speed=0.008, white_balance="auto")
Image processing corrections applied to the image.
__init__(self, saturation_adjustment=1.0, brightness_adjustment=1.0, contrast_adjustment=1.0, color_space="linear")
Metadata for radiation camera image export (Helios v1.3.58+).
__init__(self, path="")
Initialize CameraMetadata with default values.
Camera properties for radiation model cameras.
__init__(self, camera_resolution=None, focal_plane_distance=1.0, lens_diameter=0.05, HFOV=20.0, FOV_aspect_ratio=0.0, lens_focal_length=0.05, sensor_width_mm=35.0, manufacturer="", model="generic", lens_make="", lens_model="", lens_specification="", exposure="auto", shutter_speed=1.0/125.0, white_balance="auto", camera_zoom=1.0)
Initialize camera properties with defaults matching C++ CameraProperties.
to_array(self)
Convert to array format expected by C++ interface.
Raised when RadiationModel operations fail.
High-level interface for radiation modeling and ray tracing.
str getBackendName(self)
Get the name of the active ray tracing backend.
setDirectRayCount(self, str band_label, int ray_count)
Set direct ray count for radiation band.
float integrateSpectrum(self, object_spectrum, float wavelength_min=None, float wavelength_max=None, int source_id=None, camera_spectrum=None)
Integrate spectrum with optional source/camera spectra and wavelength range.
__del__(self)
Destructor to ensure GPU resources freed even without 'with' statement.
getCameraPosition(self, str camera_label)
Get camera position.
disableMessages(self)
Disable RadiationModel status messages.
int addRectangleRadiationSource(self, position, size, rotation)
Add a rectangle (planar) radiation source.
interpolateSpectrumFromPrimitiveData(self, List[int] primitive_uuids, List[str] spectra_labels, List[float] values, str primitive_data_query_label, str primitive_data_radprop_label)
Interpolate spectral properties based on primitive data values.
runBand(self, band_label)
Run radiation simulation for single band or multiple bands.
writeCameraImageDataEXR(self, str camera, band, str imagefile_base, str image_path="./", int frame=-1)
Write camera pixel data to an EXR file with lossless float compression.
setSourceSpectrum(self, source_id, spectrum)
Set radiation spectrum for source(s).
str writeCameraImage(self, str camera, List[str] bands, str imagefile_base, str image_path="./", int frame=-1, float flux_to_pixel_conversion=1.0)
Write camera image to file and return output filename.
List[float] getCameraPixelData(self, str camera_label, str band_label)
Get camera pixel data for specific band.
setCameraOrientation(self, str camera_label, direction)
Set camera orientation.
get_native_ptr(self)
Get native pointer for advanced operations.
addRadiationBand(self, str band_label, float wavelength_min=None, float wavelength_max=None)
Add radiation band with optional wavelength bounds.
setScatteringDepth(self, str label, int depth)
Set scattering depth for radiation band.
getSourcePosition(self, int source_id)
Get position of a radiation source.
addRadiationCameraFromLibrary(self, str camera_label, str library_camera_label, position, lookat, int antialiasing_samples=1, Optional[List[str]] band_labels=None)
Add radiation camera loading all properties from camera library.
updateCameraParameters(self, str camera_label, CameraProperties camera_properties)
Update camera parameters for an existing camera.
setCameraPosition(self, str camera_label, position)
Set camera position.
enableMessages(self)
Enable RadiationModel status messages.
setCameraSpectralResponse(self, str camera_label, str band_label, str global_data)
Set camera spectral response from global data.
interpolateSpectrumFromObjectData(self, List[int] object_ids, List[str] spectra_labels, List[float] values, str object_data_query_label, str primitive_data_radprop_label)
Interpolate spectral properties based on object data values.
setSourceFlux(self, source_id, str label, float flux)
Set source flux for single source or multiple sources.
writeImageBoundingBoxes(self, str camera_label, primitive_data_labels=None, object_data_labels=None, object_class_ids=None, str image_file="", str classes_txt_file="classes.txt", str image_path="./")
Write image bounding boxes for object detection training.
writeDepthImageDataEXR(self, str camera_label, str imagefile_base, str image_path="./", int frame=-1)
Write depth image data to an EXR file with lossless float compression.
deleteRadiationSource(self, int source_id)
Delete a radiation source.
setSourcePosition(self, int source_id, position)
Set position of a radiation source.
_check_context_alive(self)
Raise if the owning Context has been destroyed (see Context.check_context_alive).
'np.ndarray' getObjectDataLabelMap(self, str camera, str object_data_label, float padvalue=float('nan'))
Return a per-pixel object-data label map for a camera as a NumPy array.
setDiffuseRadiationExtinctionCoeff(self, str label, float K, peak_direction)
Set diffuse radiation extinction coefficient with directional bias.
setCameraPixelData(self, str camera_label, str band_label, List[float] pixel_data)
Set camera pixel data for specific band.
getCameraOrientation(self, str camera_label)
Get camera orientation.
enforcePeriodicBoundary(self, str boundary)
Enforce periodic boundary conditions.
writeNormDepthImage(self, str camera_label, str imagefile_base, float max_depth, str image_path="./", int frame=-1)
Write normalized depth image as grayscale JPEG.
enableCameraMetadata(self, camera_labels)
Enable automatic JSON metadata file writing for camera(s).
scaleSpectrumRandomly(self, str existing_label, str new_label, float min_scale, float max_scale)
Scale spectrum with random factor and store as new label.
bool probeAnyGPUBackend()
Probe whether any compiled-in GPU backend is available on this system.
writePrimitiveDataLabelMap(self, str camera, str primitive_data_label, str imagefile_base, str image_path="./", int frame=-1, float padvalue=float('nan'))
Write a per-pixel primitive-data label map for a camera to a text file.
setSourceSpectrumIntegral(self, int source_id, float source_integral, float wavelength_min=None, float wavelength_max=None)
Set source spectrum integral value.
setDiffuseRayCount(self, str band_label, int ray_count)
Set diffuse ray count for radiation band.
bool isSIFCamera(self, str camera_label)
Return True if the camera was registered via :meth:addSIFCamera (vs.
setCameraLookat(self, str camera_label, lookat)
Set camera lookat point.
int addDiskRadiationSource(self, position, float radius, rotation)
Add a disk (circular planar) radiation source.
getCameraLookat(self, str camera_label)
Get camera lookat point.
float getSourceFlux(self, int source_id, str label)
Get source flux for band.
blendSpectra(self, str new_label, List[str] spectrum_labels, List[float] weights)
Blend multiple spectra with specified weights.
float calculateGtheta(self, view_direction)
Calculate G-function (geometry factor) for given view direction.
int addSunSphereRadiationSource(self, float radius, float zenith, float azimuth, float position_scaling=1.0, float angular_width=0.53, float flux_scaling=1.0)
Add sun sphere radiation source.
bool doesBandExist(self, str label)
Check if a radiation band exists.
setCameraSpectralResponseFromLibrary(self, str camera_label, str camera_library_name)
Set camera spectral response from standard camera library.
setDiffuseSpectrumIntegral(self, float spectrum_integral, float wavelength_min=None, float wavelength_max=None, str band_label=None)
Set diffuse spectrum integral.
'np.ndarray' getPrimitiveDataLabelMap(self, str camera, str primitive_data_label, float padvalue=float('nan'))
Return a per-pixel primitive-data label map for a camera as a NumPy array.
scaleSpectrum(self, str existing_label, new_label_or_scale, float scale_factor=None)
Scale spectrum in-place or to new label.
writeObjectDataLabelMap(self, str camera, str object_data_label, str imagefile_base, str image_path="./", int frame=-1, float padvalue=float('nan'))
Write a per-pixel object-data label map for a camera to a text file.
float getSkyEnergy(self)
Get total sky energy.
disableEmission(self, str label)
Disable emission for radiation band.
copyRadiationBand(self, str old_label, str new_label, float wavelength_min=None, float wavelength_max=None)
Copy existing radiation band to new label, optionally with new wavelength range.
enableEmission(self, str label)
Enable emission for radiation band.
addRadiationCamera(self, str camera_label, List[str] band_labels, position, lookat_or_direction, camera_properties=None, int antialiasing_samples=100)
Add a radiation camera to the simulation.
List[float] getTotalAbsorbedFlux(self)
Get absorbed radiation flux density for all primitives, summed over all bands.
int addSphereRadiationSource(self, position, float radius)
Add spherical radiation source.
float integrateSourceSpectrum(self, int source_id, float wavelength_min, float wavelength_max)
Integrate source spectrum over wavelength range.
__enter__(self)
Context manager entry.
optionalOutputPrimitiveData(self, str label)
Enable optional primitive data output.
str autoCalibrateCameraImage(self, str camera_label, str red_band_label, str green_band_label, str blue_band_label, str output_file_path, bool print_quality_report=False, str algorithm="MATRIX_3X3_AUTO", str ccm_export_file_path="")
Auto-calibrate camera image with color correction and return output filename.
addSIFCamera(self, str camera_label, List[str] emission_band_labels, position, lookat_or_direction, camera_properties=None, int antialiasing_samples=100)
Add a solar-induced chlorophyll fluorescence (SIF) camera.
__exit__(self, exc_type, exc_value, traceback)
Context manager exit with proper cleanup.
updateGeometry(self, Optional[List[int]] uuids=None)
Update geometry in radiation model.
setDiffuseSpectrum(self, band_label, str spectrum_label)
Set diffuse spectrum from global data label.
setMinScatterEnergy(self, str label, float energy)
Set minimum scatter energy for radiation band.
float getDiffuseFlux(self, str band_label)
Get diffuse flux for band.
setDiffuseRadiationFlux(self, str label, float flux)
Set diffuse radiation flux for band.
writeImageSegmentationMasks(self, str camera_label, primitive_data_labels=None, object_data_labels=None, object_class_ids=None, str json_filename="", str image_file="", bool append_file=False)
Write image segmentation masks in COCO JSON format.
writeCameraImageData(self, str camera, str band, str imagefile_base, str image_path="./", int frame=-1)
Write camera image data to file (ASCII format).
int addCollimatedRadiationSource(self, direction=None)
Add collimated radiation source.
writeDepthImageData(self, str camera_label, str imagefile_base, str image_path="./", int frame=-1)
Write depth image data to an ASCII text file.
List[str] getAllCameraLabels(self)
Get all camera labels.
str writeNormCameraImage(self, str camera, List[str] bands, str imagefile_base, str image_path="./", int frame=-1)
Write normalized camera image to file and return output filename.
dict getPluginInfo(self)
Get information about the radiation plugin.
_check_camera_has_pixel_data(self, str camera, List[str] bands, str operation)
Raise an actionable error if a camera/band has no rendered pixel data.
blendSpectraRandomly(self, str new_label, List[str] spectrum_labels)
Blend multiple spectra with random weights.
getNativePtr(self)
Get native pointer for advanced operations.
Camera properties for a solar-induced chlorophyll fluorescence (SIF) camera.
__init__(self, float excitation_bin_width_nm=10.0, int excitation_scattering_depth=0, **kwargs)
excitation_scattering_depth
Scattering depth for the auto-generated.
excitation_bin_width_nm
Excitation wavelength bin width in nm.
_radiation_working_directory()
Context manager that temporarily changes working directory to where RadiationModel assets are located...