431 TypeError: If context is not a Context instance
432 RadiationModelError: If radiation plugin is not available
435 if not isinstance(context, Context):
436 raise TypeError(f
"RadiationModel requires a Context instance, got {type(context).__name__}")
452 registry = get_plugin_registry()
454 if not registry.is_plugin_available(
'radiation'):
456 plugin_info = registry.get_plugin_capabilities()
457 available_plugins = registry.get_available_plugins()
460 "RadiationModel requires the 'radiation' plugin which is not available.\n\n"
461 "The radiation plugin provides GPU-accelerated ray tracing with runtime\n"
462 "backend auto-detection (OptiX 8 -> OptiX 6 -> Vulkan).\n"
463 "System requirements (at least one backend):\n"
464 "- Vulkan: Vulkan loader library (macOS/Linux); no extra packages on Windows\n"
465 "- OptiX 8.1: NVIDIA GPU with driver >= 560 and CUDA 12.0+\n"
466 "- OptiX 6.5: NVIDIA GPU with driver < 560 and CUDA 9.0+\n\n"
467 "To enable radiation modeling:\n"
468 "1. Build PyHelios with radiation plugin:\n"
469 " build_scripts/build_helios --plugins radiation\n"
470 "2. Or build with multiple plugins:\n"
471 " build_scripts/build_helios --plugins radiation,visualizer,weberpenntree\n"
472 f
"\nCurrently available plugins: {available_plugins}"
476 alternatives = registry.suggest_alternatives(
'radiation')
478 error_msg += f
"\n\nAlternative plugins available: {alternatives}"
479 error_msg +=
"\nConsider using energybalance or leafoptics for thermal modeling."
486 self.
radiation_model = radiation_wrapper.createRadiationModel(context.getNativePtr())
489 "Failed to create RadiationModel instance. "
490 "This may indicate a problem with the native library or GPU initialization."
492 logger.info(
"RadiationModel created successfully")
499 except Exception
as e:
503 """Raise if the owning Context has been destroyed (see Context.check_context_alive)."""
504 check_context_alive(getattr(self,
"context",
None),
"RadiationModel")
507 band_labels: List[str],
509 """Warn when bands have explicit wavelength bounds and the camera has a
512 The two combined silently produce wrong colors. Helios computes camera
513 pixels by integrating surface reflectance against the camera's spectral
514 response curve, but computes SCATTERED flux by integrating over the
515 band's wavelength bounds instead. When bounds are absent Helios
516 deliberately falls back to the camera-weighted average for scattering,
517 so both paths agree; setting bounds breaks that agreement.
519 With scattering enabled most camera-visible light is scattered, so the
520 image carries band-limited color the camera never expected. Low-chroma
521 natural surfaces are hit hardest: a measured soil spectrum whose true
522 red/green ratio is 1.2 renders at 2.6 -- brown soil comes out pink --
523 while saturated colorboard-style spectra survive and look fine, which
524 makes the problem easy to miss.
526 Nothing errors and no output looks obviously broken, so this is a
527 warning at the point the camera is created rather than a failure.
529 bounded = [b
for b
in band_labels
if b
in getattr(self,
"_bounded_bands", ())]
534 f
"Camera '{camera_label}' ({operation}) uses a spectral response, but "
535 f
"band(s) {bounded} were created with explicit wavelength bounds. "
536 f
"Camera pixels integrate reflectance against the spectral response "
537 f
"while scattered flux integrates over the band bounds, so rendered "
538 f
"colors will be skewed (brown surfaces tend to render pink). Create "
539 f
"these bands without wavelength_min/wavelength_max -- the camera's "
540 f
"spectral response defines what each band measures."
544 """Raise an actionable error if a camera/band has no rendered pixel data.
546 A camera's pixel data is populated only by ``runBand()``, and only for
547 the bands passed to that call and for cameras that already existed when
548 it ran. Requesting an image for an unrendered camera/band otherwise
549 reaches the native layer as a bare ``invalid map<K, T> key`` from
550 ``std::map::at`` -- see GitHub issue #4. This preflight turns that into a
551 message naming the camera, the band, and the call the user is missing.
553 This check is load-bearing rather than merely cosmetic, and must not be
554 removed on the grounds that helios-core v1.3.79 addressed the same case
555 upstream. The upstream change makes the native call return an empty
556 filename instead of throwing, so without this preflight an unrendered
557 camera would look like a successful write that produced no file.
558 :meth:`_check_image_was_written` is the backstop for the failure modes
559 this preflight cannot see, such as an unwritable output directory.
562 known_cameras = radiation_wrapper.getAllCameraLabels(self.
radiation_model)
568 if known_cameras
is not None and camera
not in known_cameras:
570 f
"Cannot {operation}: camera '{camera}' does not exist. "
571 f
"Add it with addRadiationCamera() before calling runBand(). "
572 f
"Existing cameras: {sorted(known_cameras) if known_cameras else 'none'}"
577 radiation_wrapper.getCameraPixelData(self.
radiation_model, camera, band)
580 f
"Cannot {operation}: camera '{camera}' has no rendered pixel data "
581 f
"for band '{band}'. Call runBand() with this band after adding the "
582 f
"camera -- e.g. runBand({list(bands)!r}). Note that runBand() only "
583 f
"renders the bands passed to it, and only for cameras that already "
584 f
"exist when it runs."
588 image_path: str, operation: str):
589 """Raise if the native layer reported a failed write via an empty filename.
591 helios-core v1.3.79 changed ``writeCameraImage``/``writeNormCameraImage`` to
592 return an empty string on failure rather than throwing, so no exception and no
593 error code reaches Python. Returning that empty string to the caller -- or
594 logging it as "written to: " -- would present a write that produced no file as
595 a success, which the fail-fast policy forbids.
597 :meth:`_check_camera_has_pixel_data` already rejects the unrendered-camera and
598 unrendered-band cases before the native call, so by the time an empty filename
599 gets here the likeliest cause is an output directory that does not exist or
606 f
"Cannot {operation}: the native library reported a failed write for camera "
607 f
"'{camera}' (bands {list(bands)!r}) by returning an empty filename, so no "
608 f
"image file was created. The camera has rendered pixel data, so the most "
609 f
"likely cause is that the output directory '{image_path}' does not exist "
610 f
"or is not writable. Verify the path exists and is writable."
614 """Context manager entry."""
617 def __exit__(self, exc_type, exc_value, traceback):
618 """Context manager exit with proper cleanup."""
622 logger.debug(
"RadiationModel destroyed successfully")
623 except Exception
as e:
624 logger.warning(f
"Error destroying RadiationModel: {e}")
629 """Destructor to ensure GPU resources freed even without 'with' statement."""
634 except Exception
as e:
636 warnings.warn(f
"Error in RadiationModel.__del__: {e}")
639 """Get native pointer for advanced operations."""
643 """Get native pointer for advanced operations. (Legacy naming for compatibility)"""
646 @require_plugin('radiation', 'disable status messages')
648 """Disable RadiationModel status messages."""
652 @require_plugin('radiation', 'enable status messages')
654 """Enable RadiationModel status messages."""
658 @require_plugin('radiation', 'add radiation band')
659 def addRadiationBand(self, band_label: str, wavelength_min: float =
None, wavelength_max: float =
None):
661 Add radiation band with optional wavelength bounds.
664 band_label: Name/label for the radiation band
665 wavelength_min: Optional minimum wavelength (nm)
666 wavelength_max: Optional maximum wavelength (nm)
669 validate_band_label(band_label,
"band_label",
"addRadiationBand")
670 if wavelength_min
is not None and wavelength_max
is not None:
671 validate_wavelength_range(wavelength_min, wavelength_max,
"wavelength_min",
"wavelength_max",
"addRadiationBand")
673 radiation_wrapper.addRadiationBandWithWavelengths(self.
radiation_model, band_label, wavelength_min, wavelength_max)
675 logger.debug(f
"Added radiation band {band_label}: {wavelength_min}-{wavelength_max} nm")
680 logger.debug(f
"Added radiation band: {band_label}")
682 @require_plugin('radiation', 'copy radiation band')
683 @validate_radiation_band_params
684 def copyRadiationBand(self, old_label: str, new_label: str, wavelength_min: float =
None, wavelength_max: float =
None):
686 Copy existing radiation band to new label, optionally with new wavelength range.
689 old_label: Existing band label to copy
690 new_label: New label for the copied band
691 wavelength_min: Optional minimum wavelength for new band (nm)
692 wavelength_max: Optional maximum wavelength for new band (nm)
695 >>> # Copy band with same wavelength range
696 >>> radiation.copyRadiationBand("SW", "SW_copy")
698 >>> # Copy band with different wavelength range
699 >>> radiation.copyRadiationBand("full_spectrum", "PAR", 400, 700)
701 if wavelength_min
is not None and wavelength_max
is not None:
702 validate_wavelength_range(wavelength_min, wavelength_max,
"wavelength_min",
"wavelength_max",
"copyRadiationBand")
705 radiation_wrapper.copyRadiationBand(self.
radiation_model, old_label, new_label, wavelength_min, wavelength_max)
706 if wavelength_min
is not None:
708 logger.debug(f
"Copied radiation band {old_label} to {new_label} with wavelengths {wavelength_min}-{wavelength_max} nm")
715 logger.debug(f
"Copied radiation band {old_label} to {new_label}")
717 @require_plugin('radiation', 'add radiation source')
718 @validate_collimated_source_params
721 Add collimated radiation source.
724 direction: Optional direction vector. Can be tuple (x, y, z), vec3, or None for default direction.
729 if direction
is None:
731 source_id = radiation_wrapper.addCollimatedRadiationSourceDefault(self.
radiation_model)
734 if hasattr(direction,
'x')
and hasattr(direction,
'y')
and hasattr(direction,
'z'):
736 x, y, z = direction.x, direction.y, direction.z
737 elif hasattr(direction,
'radius')
and hasattr(direction,
'elevation')
and hasattr(direction,
'azimuth'):
741 elevation = direction.elevation
742 azimuth = direction.azimuth
743 x = r * math.cos(elevation) * math.cos(azimuth)
744 y = r * math.cos(elevation) * math.sin(azimuth)
745 z = r * math.sin(elevation)
750 if len(direction) != 3:
751 raise TypeError(f
"Direction must be a 3-element tuple, vec3, or SphericalCoord, got {type(direction).__name__} with {len(direction)} elements")
753 except (TypeError, AttributeError):
755 raise TypeError(f
"Direction must be a tuple, vec3, or SphericalCoord, got {type(direction).__name__}")
756 source_id = radiation_wrapper.addCollimatedRadiationSourceVec3(self.
radiation_model, x, y, z)
758 logger.debug(f
"Added collimated radiation source: ID {source_id}")
761 @require_plugin('radiation', 'add spherical radiation source')
762 @validate_sphere_source_params
765 Add spherical radiation source.
768 position: Position of the source. Can be tuple (x, y, z) or vec3.
769 radius: Radius of the spherical source
774 validate_position_like(position,
"position",
"addSphereRadiationSource")
776 if hasattr(position,
'x')
and hasattr(position,
'y')
and hasattr(position,
'z'):
777 x, y, z = position.x, position.y, position.z
781 source_id = radiation_wrapper.addSphereRadiationSource(self.
radiation_model, x, y, z, radius)
782 logger.debug(f
"Added sphere radiation source: ID {source_id} at ({x}, {y}, {z}) with radius {radius}")
785 @require_plugin('radiation', 'add sun radiation source')
786 @validate_sun_sphere_params
788 position_scaling: float = 1.0, angular_width: float = 0.53,
789 flux_scaling: float = 1.0) -> int:
791 Add sun sphere radiation source.
794 radius: Radius of the sun sphere
795 zenith: Zenith angle (degrees)
796 azimuth: Azimuth angle (degrees)
797 position_scaling: Position scaling factor
798 angular_width: Angular width of the sun (degrees)
799 flux_scaling: Flux scaling factor
805 source_id = radiation_wrapper.addSunSphereRadiationSource(
806 self.
radiation_model, radius, zenith, azimuth, position_scaling, angular_width, flux_scaling
808 logger.debug(f
"Added sun radiation source: ID {source_id}")
811 @require_plugin('radiation', 'set source position')
814 Set position of a radiation source.
816 Allows dynamic repositioning of radiation sources during simulation,
817 useful for time-series modeling or moving light sources.
820 source_id: ID of the radiation source
821 position: New position as vec3, SphericalCoord, or list/tuple [x, y, z]
824 >>> source_id = radiation.addCollimatedRadiationSource()
825 >>> radiation.setSourcePosition(source_id, [10, 20, 30])
826 >>> from pyhelios.types import vec3
827 >>> radiation.setSourcePosition(source_id, vec3(15, 25, 35))
829 if not isinstance(source_id, int)
or source_id < 0:
830 raise ValueError(f
"Source ID must be a non-negative integer, got {source_id}")
831 validate_direction_like(position,
"position",
"setSourcePosition")
833 radiation_wrapper.setSourcePosition(self.
radiation_model, source_id, position)
834 logger.debug(f
"Updated position for radiation source {source_id}")
836 @require_plugin('radiation', 'add rectangle radiation source')
839 Add a rectangle (planar) radiation source.
841 Rectangle sources are ideal for modeling artificial lighting such as
842 LED panels, grow lights, or window light sources.
845 position: Center position as vec3 or list [x, y, z]
846 size: Rectangle dimensions as vec2 or list [width, height]
847 rotation: Rotation vector as vec3 or list [rx, ry, rz] (Euler angles in radians)
853 >>> from pyhelios.types import vec3, vec2
854 >>> source_id = radiation.addRectangleRadiationSource(
855 ... position=vec3(0, 0, 5),
857 ... rotation=vec3(0, 0, 0)
859 >>> radiation.setSourceFlux(source_id, "PAR", 500.0)
861 validate_position_like(position,
"position",
"addRectangleRadiationSource")
862 validate_size_like(size,
"size",
"addRectangleRadiationSource")
863 validate_position_like(rotation,
"rotation",
"addRectangleRadiationSource")
865 return radiation_wrapper.addRectangleRadiationSource(self.
radiation_model, position, size, rotation)
867 @require_plugin('radiation', 'add disk radiation source')
870 Add a disk (circular planar) radiation source.
872 Disk sources are useful for modeling circular light sources such as
873 spotlights, circular LED arrays, or solar simulators.
876 position: Center position as vec3 or list [x, y, z]
878 rotation: Rotation vector as vec3 or list [rx, ry, rz] (Euler angles in radians)
884 >>> from pyhelios.types import vec3
885 >>> source_id = radiation.addDiskRadiationSource(
886 ... position=vec3(0, 0, 5),
888 ... rotation=vec3(0, 0, 0)
890 >>> radiation.setSourceFlux(source_id, "PAR", 300.0)
892 validate_position_like(position,
"position",
"addDiskRadiationSource")
893 validate_position_like(rotation,
"rotation",
"addDiskRadiationSource")
895 raise ValueError(f
"Radius must be positive, got {radius}")
897 return radiation_wrapper.addDiskRadiationSource(self.
radiation_model, position, radius, rotation)
900 @require_plugin('radiation', 'manage source spectrum')
903 Set radiation spectrum for source(s).
905 Spectral distributions define how radiation intensity varies with wavelength,
906 essential for realistic modeling of different light sources (sunlight, LEDs, etc.).
909 source_id: Source ID (int) or list of source IDs
911 - Spectrum data as list of (wavelength, value) tuples
912 - Global data label string
915 >>> # Define custom LED spectrum
917 ... (400, 0.0), (450, 0.3), (500, 0.8),
918 ... (550, 0.5), (600, 0.2), (700, 0.0)
920 >>> radiation.setSourceSpectrum(source_id, led_spectrum)
922 >>> # Use predefined spectrum from global data
923 >>> radiation.setSourceSpectrum(source_id, "D65_illuminant")
925 >>> # Apply same spectrum to multiple sources
926 >>> radiation.setSourceSpectrum([src1, src2, src3], led_spectrum)
929 radiation_wrapper.setSourceSpectrum(self.
radiation_model, source_id, spectrum)
930 logger.debug(f
"Set spectrum for source(s) {source_id}")
932 @require_plugin('radiation', 'configure source spectrum')
934 wavelength_min: float =
None, wavelength_max: float =
None):
936 Set source spectrum integral value.
938 Normalizes the spectrum so that its integral equals the specified value,
939 useful for calibrating source intensity.
943 source_integral: Target integral value
944 wavelength_min: Optional minimum wavelength for integration range
945 wavelength_max: Optional maximum wavelength for integration range
948 >>> radiation.setSourceSpectrumIntegral(source_id, 1000.0)
949 >>> radiation.setSourceSpectrumIntegral(source_id, 500.0, 400, 700) # PAR range
951 if not isinstance(source_id, int)
or source_id < 0:
952 raise ValueError(f
"Source ID must be a non-negative integer, got {source_id}")
953 if source_integral < 0:
954 raise ValueError(f
"Source integral must be non-negative, got {source_integral}")
957 radiation_wrapper.setSourceSpectrumIntegral(self.
radiation_model, source_id, source_integral,
958 wavelength_min, wavelength_max)
959 logger.debug(f
"Set spectrum integral for source {source_id}: {source_integral}")
962 @require_plugin('radiation', 'integrate spectrum')
964 wavelength_max: float =
None, source_id: int =
None,
965 camera_spectrum=
None) -> float:
967 Integrate spectrum with optional source/camera spectra and wavelength range.
969 This unified method handles multiple integration scenarios:
970 - Basic: Total spectrum integration
971 - Range: Integration over wavelength range
972 - Source: Integration weighted by source spectrum
973 - Camera: Integration weighted by camera spectral response
974 - Full: Integration with both source and camera spectra
977 object_spectrum: Object spectrum as list of (wavelength, value) tuples/vec2
978 wavelength_min: Optional minimum wavelength for integration range
979 wavelength_max: Optional maximum wavelength for integration range
980 source_id: Optional source ID for source spectrum weighting
981 camera_spectrum: Optional camera spectrum for camera response weighting
987 >>> leaf_reflectance = [(400, 0.1), (500, 0.4), (600, 0.6), (700, 0.5)]
989 >>> # Total integration
990 >>> total = radiation.integrateSpectrum(leaf_reflectance)
992 >>> # PAR range (400-700nm)
993 >>> par = radiation.integrateSpectrum(leaf_reflectance, 400, 700)
995 >>> # With source spectrum
996 >>> source_weighted = radiation.integrateSpectrum(
997 ... leaf_reflectance, 400, 700, source_id=sun_source
1000 >>> # With camera response
1001 >>> camera_response = [(400, 0.2), (550, 1.0), (700, 0.3)]
1002 >>> camera_weighted = radiation.integrateSpectrum(
1003 ... leaf_reflectance, camera_spectrum=camera_response
1007 return radiation_wrapper.integrateSpectrum(self.
radiation_model, object_spectrum,
1008 wavelength_min, wavelength_max,
1009 source_id, camera_spectrum)
1011 @require_plugin('radiation', 'integrate source spectrum')
1014 Integrate source spectrum over wavelength range.
1017 source_id: Source ID
1018 wavelength_min: Minimum wavelength
1019 wavelength_max: Maximum wavelength
1022 Integrated source spectrum value
1025 >>> par_flux = radiation.integrateSourceSpectrum(source_id, 400, 700)
1027 if not isinstance(source_id, int)
or source_id < 0:
1028 raise ValueError(f
"Source ID must be a non-negative integer, got {source_id}")
1030 return radiation_wrapper.integrateSourceSpectrum(self.
radiation_model, source_id,
1031 wavelength_min, wavelength_max)
1034 @require_plugin('radiation', 'scale spectrum')
1035 def scaleSpectrum(self, existing_label: str, new_label_or_scale, scale_factor: float =
None):
1037 Scale spectrum in-place or to new label.
1039 Useful for adjusting spectrum intensities or creating variations of
1040 existing spectra for sensitivity analysis.
1042 Supports two call patterns:
1043 - scaleSpectrum("label", scale) -> scales in-place
1044 - scaleSpectrum("existing", "new", scale) -> creates new scaled spectrum
1047 existing_label: Existing global data label
1048 new_label_or_scale: Either new label string (if creating new) or scale factor (if in-place)
1049 scale_factor: Scale factor (required only if new_label_or_scale is a string)
1052 >>> # In-place scaling
1053 >>> radiation.scaleSpectrum("leaf_reflectance", 1.2)
1055 >>> # Create new scaled spectrum
1056 >>> radiation.scaleSpectrum("leaf_reflectance", "scaled_leaf", 1.5)
1058 if not isinstance(existing_label, str)
or not existing_label.strip():
1059 raise ValueError(
"Existing label must be a non-empty string")
1063 new_label_or_scale, scale_factor)
1064 logger.debug(f
"Scaled spectrum '{existing_label}'")
1066 @require_plugin('radiation', 'scale spectrum randomly')
1068 min_scale: float, max_scale: float):
1070 Scale spectrum with random factor and store as new label.
1072 Useful for creating stochastic variations in spectral properties for
1073 Monte Carlo simulations or uncertainty quantification.
1076 existing_label: Existing global data label
1077 new_label: New global data label for scaled spectrum
1078 min_scale: Minimum scale factor
1079 max_scale: Maximum scale factor
1082 >>> # Create random variation of leaf reflectance
1083 >>> radiation.scaleSpectrumRandomly("leaf_base", "leaf_variant", 0.8, 1.2)
1085 if not isinstance(existing_label, str)
or not existing_label.strip():
1086 raise ValueError(
"Existing label must be a non-empty string")
1087 if not isinstance(new_label, str)
or not new_label.strip():
1088 raise ValueError(
"New label must be a non-empty string")
1089 if min_scale >= max_scale:
1090 raise ValueError(f
"min_scale ({min_scale}) must be less than max_scale ({max_scale})")
1093 radiation_wrapper.scaleSpectrumRandomly(self.
radiation_model, existing_label, new_label,
1094 min_scale, max_scale)
1095 logger.debug(f
"Scaled spectrum '{existing_label}' randomly to '{new_label}'")
1097 @require_plugin('radiation', 'blend spectra')
1098 def blendSpectra(self, new_label: str, spectrum_labels: List[str], weights: List[float]):
1100 Blend multiple spectra with specified weights.
1102 Creates weighted combination of spectra, useful for mixing material properties
1103 or creating composite light sources.
1106 new_label: New global data label for blended spectrum
1107 spectrum_labels: List of spectrum labels to blend
1108 weights: List of weights (must sum to reasonable values, same length as labels)
1111 >>> # Mix two leaf types (70% type A, 30% type B)
1112 >>> radiation.blendSpectra("mixed_leaf",
1113 ... ["leaf_type_a", "leaf_type_b"],
1117 if not isinstance(new_label, str)
or not new_label.strip():
1118 raise ValueError(
"New label must be a non-empty string")
1119 if len(spectrum_labels) != len(weights):
1120 raise ValueError(f
"Number of labels ({len(spectrum_labels)}) must match number of weights ({len(weights)})")
1121 if not spectrum_labels:
1122 raise ValueError(
"At least one spectrum label required")
1125 radiation_wrapper.blendSpectra(self.
radiation_model, new_label, spectrum_labels, weights)
1126 logger.debug(f
"Blended {len(spectrum_labels)} spectra into '{new_label}'")
1128 @require_plugin('radiation', 'blend spectra randomly')
1131 Blend multiple spectra with random weights.
1133 Creates random combinations of spectra, useful for generating diverse
1134 material properties in stochastic simulations.
1137 new_label: New global data label for blended spectrum
1138 spectrum_labels: List of spectrum labels to blend
1141 >>> # Create random mixture of leaf spectra
1142 >>> radiation.blendSpectraRandomly("random_leaf",
1143 ... ["young_leaf", "mature_leaf", "senescent_leaf"]
1146 if not isinstance(new_label, str)
or not new_label.strip():
1147 raise ValueError(
"New label must be a non-empty string")
1148 if not spectrum_labels:
1149 raise ValueError(
"At least one spectrum label required")
1152 radiation_wrapper.blendSpectraRandomly(self.
radiation_model, new_label, spectrum_labels)
1153 logger.debug(f
"Blended {len(spectrum_labels)} spectra randomly into '{new_label}'")
1156 @require_plugin('radiation', 'interpolate spectrum from data')
1158 spectra_labels: List[str], values: List[float],
1159 primitive_data_query_label: str,
1160 primitive_data_radprop_label: str):
1162 Interpolate spectral properties based on primitive data values.
1164 Automatically assigns spectra to primitives by interpolating between
1165 reference spectra based on continuous data values (e.g., age, moisture, etc.).
1168 primitive_uuids: List of primitive UUIDs to assign spectra
1169 spectra_labels: List of reference spectrum labels
1170 values: List of data values corresponding to each spectrum
1171 primitive_data_query_label: Primitive data label containing query values
1172 primitive_data_radprop_label: Primitive data label to store assigned spectra
1175 >>> # Assign leaf reflectance based on age
1176 >>> leaf_patches = context.getAllUUIDs("patch")
1177 >>> radiation.interpolateSpectrumFromPrimitiveData(
1178 ... primitive_uuids=leaf_patches,
1179 ... spectra_labels=["young_leaf", "mature_leaf", "old_leaf"],
1180 ... values=[0.0, 50.0, 100.0], # Days since emergence
1181 ... primitive_data_query_label="leaf_age",
1182 ... primitive_data_radprop_label="reflectance"
1185 if not isinstance(primitive_uuids, (list, tuple))
or not primitive_uuids:
1186 raise ValueError(
"Primitive UUIDs must be a non-empty list")
1187 if not isinstance(spectra_labels, (list, tuple))
or not spectra_labels:
1188 raise ValueError(
"Spectra labels must be a non-empty list")
1189 if not isinstance(values, (list, tuple))
or not values:
1190 raise ValueError(
"Values must be a non-empty list")
1191 if len(spectra_labels) != len(values):
1192 raise ValueError(f
"Number of spectra ({len(spectra_labels)}) must match number of values ({len(values)})")
1195 radiation_wrapper.interpolateSpectrumFromPrimitiveData(
1197 primitive_data_query_label, primitive_data_radprop_label
1199 logger.debug(f
"Interpolated spectra for {len(primitive_uuids)} primitives")
1201 @require_plugin('radiation', 'interpolate spectrum from object data')
1203 spectra_labels: List[str], values: List[float],
1204 object_data_query_label: str,
1205 primitive_data_radprop_label: str):
1207 Interpolate spectral properties based on object data values.
1209 Automatically assigns spectra to object primitives by interpolating between
1210 reference spectra based on continuous object-level data values.
1213 object_ids: List of object IDs
1214 spectra_labels: List of reference spectrum labels
1215 values: List of data values corresponding to each spectrum
1216 object_data_query_label: Object data label containing query values
1217 primitive_data_radprop_label: Primitive data label to store assigned spectra
1220 >>> # Assign tree reflectance based on health index
1221 >>> tree_ids = [tree1_id, tree2_id, tree3_id]
1222 >>> radiation.interpolateSpectrumFromObjectData(
1223 ... object_ids=tree_ids,
1224 ... spectra_labels=["healthy_tree", "stressed_tree", "diseased_tree"],
1225 ... values=[1.0, 0.5, 0.0], # Health index
1226 ... object_data_query_label="health_index",
1227 ... primitive_data_radprop_label="reflectance"
1230 if not isinstance(object_ids, (list, tuple))
or not object_ids:
1231 raise ValueError(
"Object IDs must be a non-empty list")
1232 if not isinstance(spectra_labels, (list, tuple))
or not spectra_labels:
1233 raise ValueError(
"Spectra labels must be a non-empty list")
1234 if not isinstance(values, (list, tuple))
or not values:
1235 raise ValueError(
"Values must be a non-empty list")
1236 if len(spectra_labels) != len(values):
1237 raise ValueError(f
"Number of spectra ({len(spectra_labels)}) must match number of values ({len(values)})")
1240 radiation_wrapper.interpolateSpectrumFromObjectData(
1242 object_data_query_label, primitive_data_radprop_label
1244 logger.debug(f
"Interpolated spectra for {len(object_ids)} objects")
1246 @require_plugin('radiation', 'set ray count')
1248 """Set direct ray count for radiation band."""
1249 validate_band_label(band_label,
"band_label",
"setDirectRayCount")
1250 validate_ray_count(ray_count,
"ray_count",
"setDirectRayCount")
1252 radiation_wrapper.setDirectRayCount(self.
radiation_model, band_label, ray_count)
1254 @require_plugin('radiation', 'set ray count')
1256 """Set diffuse ray count for radiation band."""
1257 validate_band_label(band_label,
"band_label",
"setDiffuseRayCount")
1258 validate_ray_count(ray_count,
"ray_count",
"setDiffuseRayCount")
1260 radiation_wrapper.setDiffuseRayCount(self.
radiation_model, band_label, ray_count)
1262 @require_plugin('radiation', 'set radiation flux')
1264 """Set diffuse radiation flux for band."""
1265 validate_band_label(label,
"label",
"setDiffuseRadiationFlux")
1266 validate_flux_value(flux,
"flux",
"setDiffuseRadiationFlux")
1268 radiation_wrapper.setDiffuseRadiationFlux(self.
radiation_model, label, flux)
1270 @require_plugin('radiation', 'configure diffuse radiation')
1273 Set diffuse radiation extinction coefficient with directional bias.
1275 Models directionally-biased diffuse radiation (e.g., sky radiation with zenith peak).
1279 K: Extinction coefficient
1280 peak_direction: Peak direction as vec3, SphericalCoord, or list [x, y, z]
1283 >>> from pyhelios.types import vec3
1284 >>> radiation.setDiffuseRadiationExtinctionCoeff("SW", 0.5, vec3(0, 0, 1))
1286 validate_band_label(label,
"label",
"setDiffuseRadiationExtinctionCoeff")
1288 raise ValueError(f
"Extinction coefficient must be non-negative, got {K}")
1289 validate_direction_like(peak_direction,
"peak_direction",
"setDiffuseRadiationExtinctionCoeff")
1291 radiation_wrapper.setDiffuseRadiationExtinctionCoeff(self.
radiation_model, label, K, peak_direction)
1292 logger.debug(f
"Set diffuse extinction coefficient for band '{label}': K={K}")
1294 @require_plugin('radiation', 'query diffuse flux')
1297 Get diffuse flux for band.
1300 band_label: Band label
1306 >>> flux = radiation.getDiffuseFlux("SW")
1308 validate_band_label(band_label,
"band_label",
"getDiffuseFlux")
1310 return radiation_wrapper.getDiffuseFlux(self.
radiation_model, band_label)
1312 @require_plugin('radiation', 'configure diffuse spectrum')
1315 Set diffuse spectrum from global data label.
1318 band_label: Band label (string) or list of band labels
1319 spectrum_label: Spectrum global data label
1322 >>> radiation.setDiffuseSpectrum("SW", "sky_spectrum")
1323 >>> radiation.setDiffuseSpectrum(["SW", "NIR"], "sky_spectrum")
1325 if isinstance(band_label, str):
1326 validate_band_label(band_label,
"band_label",
"setDiffuseSpectrum")
1328 for label
in band_label:
1329 validate_band_label(label,
"band_label",
"setDiffuseSpectrum")
1330 if not isinstance(spectrum_label, str)
or not spectrum_label.strip():
1331 raise ValueError(
"Spectrum label must be a non-empty string")
1334 radiation_wrapper.setDiffuseSpectrum(self.
radiation_model, band_label, spectrum_label)
1335 logger.debug(f
"Set diffuse spectrum for band(s) {band_label}")
1337 @require_plugin('radiation', 'configure diffuse spectrum')
1339 wavelength_max: float =
None, band_label: str =
None):
1341 Set diffuse spectrum integral.
1344 spectrum_integral: Integral value
1345 wavelength_min: Optional minimum wavelength
1346 wavelength_max: Optional maximum wavelength
1347 band_label: Optional specific band label (None for all bands)
1350 >>> radiation.setDiffuseSpectrumIntegral(1000.0) # All bands
1351 >>> radiation.setDiffuseSpectrumIntegral(500.0, 400, 700, band_label="PAR") # Specific band
1353 if spectrum_integral < 0:
1354 raise ValueError(f
"Spectrum integral must be non-negative, got {spectrum_integral}")
1355 if band_label
is not None:
1356 validate_band_label(band_label,
"band_label",
"setDiffuseSpectrumIntegral")
1359 radiation_wrapper.setDiffuseSpectrumIntegral(self.
radiation_model, spectrum_integral,
1360 wavelength_min, wavelength_max, band_label)
1361 logger.debug(f
"Set diffuse spectrum integral: {spectrum_integral}")
1363 @require_plugin('radiation', 'set source flux')
1364 def setSourceFlux(self, source_id, label: str, flux: float):
1365 """Set source flux for single source or multiple sources."""
1366 validate_band_label(label,
"label",
"setSourceFlux")
1367 validate_flux_value(flux,
"flux",
"setSourceFlux")
1369 if isinstance(source_id, (list, tuple)):
1371 validate_source_id_list(list(source_id),
"source_id",
"setSourceFlux")
1373 radiation_wrapper.setSourceFluxMultiple(self.
radiation_model, source_id, label, flux)
1376 validate_source_id(source_id,
"source_id",
"setSourceFlux")
1378 radiation_wrapper.setSourceFlux(self.
radiation_model, source_id, label, flux)
1381 @require_plugin('radiation', 'get source flux')
1382 @validate_get_source_flux_params
1383 def getSourceFlux(self, source_id: int, label: str) -> float:
1384 """Get source flux for band."""
1386 return radiation_wrapper.getSourceFlux(self.
radiation_model, source_id, label)
1388 @require_plugin('radiation', 'update geometry')
1389 @validate_update_geometry_params
1392 Update geometry in radiation model.
1394 The two call forms differ in more than scope, as of helios-core v1.3.79:
1396 - ``updateGeometry()`` (no argument) is **optional**. :meth:`runBand` builds
1397 the geometry itself before tracing and rebuilds it whenever primitives have
1398 been added to or deleted from the Context since the last build. Call it
1399 explicitly only to control when the cost of the build is paid.
1400 - ``updateGeometry(uuids)`` restricts the model to a subset of Context
1401 primitives, and that subset is **never** rebuilt automatically -- doing so
1402 would silently discard the subset you asked for. You must call it again
1403 yourself after modifying Context geometry.
1406 uuids: Optional list of specific UUIDs to update. If None, updates all geometry.
1409 Passing ``uuids`` latches the model into subset mode natively. Calling
1410 ``updateGeometry()`` with no argument afterwards clears that latch and
1411 returns the model to tracking the full Context.
1416 logger.debug(
"Updated all geometry in radiation model")
1422 logger.debug(f
"Updated {len(uuids)} geometry UUIDs in radiation model")
1426 @require_plugin('radiation', 'run radiation simulation')
1427 @validate_run_band_params
1430 Run radiation simulation for single band or multiple bands.
1432 PERFORMANCE NOTE: When simulating multiple radiation bands, it is HIGHLY RECOMMENDED
1433 to run all bands in a single call (e.g., runBand(["PAR", "NIR", "SW"])) rather than
1434 sequential single-band calls. This provides significant computational efficiency gains
1437 - GPU ray tracing setup is done once for all bands
1438 - Scene geometry acceleration structures are reused
1439 - GPU kernel launches are batched together
1440 - Memory transfers between CPU/GPU are minimized
1443 # EFFICIENT - Single call for multiple bands
1444 radiation.runBand(["PAR", "NIR", "SW"])
1446 # INEFFICIENT - Sequential single-band calls
1447 radiation.runBand("PAR")
1448 radiation.runBand("NIR")
1449 radiation.runBand("SW")
1452 band_label: Single band name (str) or list of band names for multi-band simulation
1455 Geometry and radiative properties are updated automatically as needed
1456 (helios-core v1.3.79+), so an explicit :meth:`updateGeometry` call is not
1457 required first. A subset build from ``updateGeometry(uuids)`` is preserved:
1458 it is never rebuilt automatically, since that would discard the subset.
1460 if isinstance(band_label, (list, tuple)):
1462 for lbl
in band_label:
1463 if not isinstance(lbl, str):
1464 raise TypeError(f
"Band labels must be strings, got {type(lbl).__name__}")
1467 logger.info(f
"Completed radiation simulation for bands: {band_label}")
1470 if not isinstance(band_label, str):
1471 raise TypeError(f
"Band label must be a string, got {type(band_label).__name__}")
1474 logger.info(f
"Completed radiation simulation for band: {band_label}")
1482 @require_plugin('radiation', 'get simulation results')
1484 """Get absorbed radiation flux density for all primitives, summed over all bands.
1487 **The returned values cannot be matched to UUIDs by position.** They are
1488 ordered by the radiation model's internal primitive ordering (grouped by
1489 parent object, built during :meth:`updateGeometry`), which is *not* the
1490 order of ``context.getAllUUIDs()`` -- that iterates an unordered map and
1491 returns a hash order. Pairing the two by index silently attributes flux
1492 to the wrong primitive. Use :meth:`getAbsorbedFlux` instead, which is
1496 This sums the ``radiation_flux_<band>`` primitive data over **every**
1497 band registered in the model. For overlapping bands -- e.g. PAR, NIR
1498 and SW, where SW already spans the other two -- the sum double-counts
1499 and is not a physically meaningful quantity. Use
1500 :meth:`getAbsorbedFlux` to get a single band's flux.
1502 Units are **W/m^2** (flux density), not watts. Because it is a density, the
1503 value does not change when a primitive's size changes: a 1x1 m and a 2x2 m
1504 patch under the same collimated source both report the same number.
1506 Summing the returned values directly (``sum(flux)``) adds flux densities of
1507 differently-sized surfaces and is not physically meaningful. To obtain
1508 absorbed power in watts, weight each primitive by its area -- via
1509 :meth:`getAbsorbedFlux`, so that flux and area refer to the same primitive::
1511 uuids = context.getAllUUIDs()
1512 flux = radiation.getAbsorbedFlux("SW")
1513 power = float((flux * context.getPrimitiveArea(uuids)).sum(dtype="float64"))
1516 Absorbed flux density per primitive in W/m^2, summed over all bands.
1519 :meth:`getAbsorbedFlux`: band-specific, UUID-aligned absorbed flux.
1522 results = radiation_wrapper.getTotalAbsorbedFlux(self.
radiation_model)
1523 logger.debug(f
"Retrieved absorbed flux data for {len(results)} primitives")
1526 @require_plugin('radiation', 'get band-specific simulation results')
1528 uuids: Optional[List[int]] =
None
1529 ) -> Union[
"np.ndarray", Dict[str,
"np.ndarray"]]:
1530 """Get per-band absorbed radiation flux density, aligned to UUIDs.
1532 ``runBand()`` stores each band's result as ``radiation_flux_<band>``
1533 primitive data: the **total** absorbed flux density for that band, i.e.
1534 direct plus diffuse plus scattered contributions. This method reads that
1535 data back keyed by UUID, so element ``i`` of the result always belongs to
1538 Prefer this over :meth:`getTotalAbsorbedFlux`, which sums over every band
1539 (double-counting overlapping bands such as PAR/NIR/SW) and whose ordering
1540 does not correspond to ``context.getAllUUIDs()``.
1543 band_label: Band name, or a list of band names to fetch at once.
1544 uuids: Primitives to query, defaulting to ``context.getAllUUIDs()``.
1545 Results follow this list's order.
1548 For a single band, a float32 array of absorbed flux density per
1549 primitive in W/m^2. For a list of bands, a dict mapping each band
1550 label to that array.
1553 RadiationModelError: If a band does not exist, or if ``runBand()`` has
1554 not been run for it.
1557 >>> radiation.runBand(["PAR", "NIR", "SW"])
1558 >>> uuids = context.getAllUUIDs()
1559 >>> par = radiation.getAbsorbedFlux("PAR") # W/m^2, PAR only
1560 >>> par_watts = par * context.getPrimitiveArea(uuids)
1561 >>> all_bands = radiation.getAbsorbedFlux(["PAR", "NIR", "SW"])
1562 >>> all_bands["SW"][0] # doctest: +SKIP
1564 single_band = isinstance(band_label, str)
1565 labels = [band_label]
if single_band
else band_label
1568 if not isinstance(labels, (list, tuple)):
1570 f
"band_label must be a string or list of strings, "
1571 f
"got {type(band_label).__name__}"
1574 raise ValueError(
"band_label list cannot be empty")
1575 for label
in labels:
1576 validate_band_label(label,
"band_label",
"getAbsorbedFlux")
1581 uuids = self.
context.getAllUUIDs()
1583 if not isinstance(uuids, (list, tuple)):
1585 f
"uuids must be a list of primitive UUIDs, "
1586 f
"got {type(uuids).__name__}"
1591 "No primitives to query: the Context contains no geometry."
1594 results: Dict[str,
"np.ndarray"] = {}
1595 for label
in labels:
1598 f
"Radiation band '{label}' does not exist. "
1599 f
"Add it with radiation.addRadiationBand('{label}') before "
1600 f
"querying its absorbed flux."
1603 data_label = f
"radiation_flux_{label}"
1606 if not self.
context.doesPrimitiveDataExist(uuids[0], data_label):
1608 f
"No absorbed flux results for band '{label}': primitive data "
1609 f
"'{data_label}' has not been written. Call "
1610 f
"radiation.runBand('{label}') (or include it in a multi-band "
1611 f
"runBand([...]) call) before querying its absorbed flux."
1618 results[label] = context_wrapper.getPrimitiveDataFloatArray(
1622 f
"Retrieved absorbed flux for bands {labels} over {len(uuids)} primitives"
1624 return results[band_label]
if single_band
else results
1627 @require_plugin('radiation', 'check band existence')
1630 Check if a radiation band exists.
1633 label: Name/label of the radiation band to check
1636 True if band exists, False otherwise
1639 >>> radiation.addRadiationBand("SW")
1640 >>> radiation.doesBandExist("SW")
1642 >>> radiation.doesBandExist("nonexistent")
1645 validate_band_label(label,
"label",
"doesBandExist")
1650 @require_plugin('radiation', 'manage radiation sources')
1653 Delete a radiation source.
1656 source_id: ID of the radiation source to delete
1659 >>> source_id = radiation.addCollimatedRadiationSource()
1660 >>> radiation.deleteRadiationSource(source_id)
1662 if not isinstance(source_id, int)
or source_id < 0:
1663 raise ValueError(f
"Source ID must be a non-negative integer, got {source_id}")
1665 radiation_wrapper.deleteRadiationSource(self.
radiation_model, source_id)
1666 logger.debug(f
"Deleted radiation source {source_id}")
1668 @require_plugin('radiation', 'query radiation sources')
1671 Get position of a radiation source.
1674 source_id: ID of the radiation source
1677 vec3 position of the source
1680 >>> source_id = radiation.addCollimatedRadiationSource()
1681 >>> position = radiation.getSourcePosition(source_id)
1682 >>> print(f"Source at: {position}")
1684 if not isinstance(source_id, int)
or source_id < 0:
1685 raise ValueError(f
"Source ID must be a non-negative integer, got {source_id}")
1687 position_list = radiation_wrapper.getSourcePosition(self.
radiation_model, source_id)
1688 from .wrappers.DataTypes
import vec3
1689 return vec3(position_list[0], position_list[1], position_list[2])
1692 @require_plugin('radiation', 'get sky energy')
1695 Get total sky energy.
1698 Total sky energy value
1701 >>> energy = radiation.getSkyEnergy()
1702 >>> print(f"Sky energy: {energy}")
1707 @require_plugin('radiation', 'calculate G-function')
1710 Calculate G-function (geometry factor) for given view direction.
1712 The G-function describes the geometric relationship between leaf area
1713 distribution and viewing direction, important for canopy radiation modeling.
1715 The G-function is computed from the geometry currently loaded in the radiation
1716 model. If no geometry has been built yet, this method builds it automatically
1717 (with a warning) so the query operates on the current context geometry. If the
1718 result is still undefined (no primitives / zero leaf area), a RuntimeError is
1719 raised rather than silently returning NaN.
1721 A subset built by ``updateGeometry(uuids)`` is never widened by this method: the
1722 G-function is reported over the subset the caller selected.
1725 view_direction: View direction as vec3 or list/tuple [x, y, z]
1731 RuntimeError: If the context has no geometry (or zero total leaf area),
1732 so the G-function is undefined.
1735 >>> from pyhelios.types import vec3
1736 >>> radiation.updateGeometry()
1737 >>> g_value = radiation.calculateGtheta(vec3(0, 0, 1))
1738 >>> print(f"G-function: {g_value}")
1740 validate_position_like(view_direction,
"view_direction",
"calculateGtheta")
1748 "calculateGtheta requires built geometry, but the radiation model "
1749 "holds a user-specified primitive subset from updateGeometry(uuids) "
1750 "and building automatically would discard it. Call "
1751 "updateGeometry(uuids) again for the subset you want, or "
1752 "updateGeometry() to use the full Context."
1755 "calculateGtheta called before geometry was built; updating radiation "
1756 "model geometry automatically. Call updateGeometry() explicitly after "
1757 "building the scene to avoid this."
1763 value = radiation_wrapper.calculateGtheta(self.
radiation_model, context_ptr, view_direction)
1765 if value
is None or math.isnan(value):
1767 "calculateGtheta returned an undefined (NaN) G-function. The radiation "
1768 "model has no geometry with positive leaf area for this context. Add "
1769 "primitives to the Context and ensure updateGeometry() succeeds before "
1770 "calling calculateGtheta()."
1774 @require_plugin('radiation', 'configure output data')
1777 Enable optional primitive data output.
1780 label: Name/label of the primitive data to output
1783 >>> radiation.optionalOutputPrimitiveData("temperature")
1785 validate_band_label(label,
"label",
"optionalOutputPrimitiveData")
1787 radiation_wrapper.optionalOutputPrimitiveData(self.
radiation_model, label)
1788 logger.debug(f
"Enabled optional output for primitive data: {label}")
1790 @require_plugin('radiation', 'configure boundary conditions')
1793 Enforce periodic boundary conditions.
1795 Periodic boundaries are useful for large-scale simulations to reduce
1796 edge effects by wrapping radiation at domain boundaries.
1799 boundary: Boundary specification string (e.g., "xy", "xyz", "x", "y", "z")
1802 >>> radiation.enforcePeriodicBoundary("xy")
1804 if not isinstance(boundary, str)
or not boundary:
1805 raise ValueError(
"Boundary specification must be a non-empty string")
1807 radiation_wrapper.enforcePeriodicBoundary(self.
radiation_model, boundary)
1808 logger.debug(f
"Enforced periodic boundary: {boundary}")
1811 @require_plugin('radiation', 'configure radiation simulation')
1812 @validate_scattering_depth_params
1814 """Set scattering depth for radiation band."""
1816 radiation_wrapper.setScatteringDepth(self.
radiation_model, label, depth)
1818 @require_plugin('radiation', 'configure radiation simulation')
1819 @validate_min_scatter_energy_params
1821 """Set minimum scatter energy for radiation band."""
1823 radiation_wrapper.setMinScatterEnergy(self.
radiation_model, label, energy)
1825 @require_plugin('radiation', 'configure radiation emission')
1827 """Disable emission for radiation band."""
1828 validate_band_label(label,
"label",
"disableEmission")
1832 @require_plugin('radiation', 'configure radiation emission')
1834 """Enable emission for radiation band."""
1835 validate_band_label(label,
"label",
"enableEmission")
1843 @require_plugin('radiation', 'add radiation camera')
1844 def addRadiationCamera(self, camera_label: str, band_labels: List[str], position, lookat_or_direction,
1845 camera_properties=
None, antialiasing_samples: int = 100):
1847 Add a radiation camera to the simulation.
1850 camera_label: Unique label string for the camera
1851 band_labels: List of radiation band labels for the camera
1852 position: Camera position as vec3 object
1853 lookat_or_direction: Either:
1854 - Lookat point as vec3 object
1855 - SphericalCoord for viewing direction
1856 camera_properties: CameraProperties instance or None for defaults
1857 antialiasing_samples: Number of antialiasing samples (default: 100)
1860 ValidationError: If parameters are invalid or have wrong types
1861 RadiationModelError: If camera creation fails
1864 >>> from pyhelios import vec3, CameraProperties
1865 >>> # Create camera looking at origin from above
1866 >>> camera_props = CameraProperties(camera_resolution=(1024, 1024))
1867 >>> radiation_model.addRadiationCamera("main_camera", ["red", "green", "blue"],
1868 ... position=vec3(0, 0, 5), lookat_or_direction=vec3(0, 0, 0),
1869 ... camera_properties=camera_props)
1872 from .wrappers
import URadiationModelWrapper
as radiation_wrapper
1873 from .wrappers.DataTypes
import SphericalCoord, vec3, make_vec3
1874 from .validation.plugins
import validate_camera_label, validate_band_labels_list, validate_antialiasing_samples
1877 validated_label = validate_camera_label(camera_label,
"camera_label",
"addRadiationCamera")
1878 validated_bands = validate_band_labels_list(band_labels,
"band_labels",
"addRadiationCamera")
1879 validated_samples = validate_antialiasing_samples(antialiasing_samples,
"antialiasing_samples",
"addRadiationCamera")
1882 if not isinstance(position, vec3):
1883 raise TypeError(
"position must be a vec3 object. Use vec3(x, y, z) to create one.")
1884 validated_position = position
1887 if isinstance(lookat_or_direction, SphericalCoord):
1888 validated_direction = lookat_or_direction
1889 elif isinstance(lookat_or_direction, vec3):
1890 validated_direction = lookat_or_direction
1892 raise TypeError(
"lookat_or_direction must be a vec3 or SphericalCoord object. Use vec3(x, y, z) or SphericalCoord to create one.")
1895 if camera_properties
is None:
1901 if hasattr(validated_direction,
'radius')
and hasattr(validated_direction,
'elevation'):
1903 direction_coords = validated_direction.to_list()
1906 if len(direction_coords) < 4:
1907 raise ValueError(
"SphericalCoord must expose radius, elevation, zenith, and azimuth")
1908 radius, elevation, azimuth = direction_coords[0], direction_coords[1], direction_coords[3]
1910 radiation_wrapper.addRadiationCameraSpherical(
1914 validated_position.x, validated_position.y, validated_position.z,
1915 radius, elevation, azimuth,
1916 camera_properties.to_array(),
1918 camera_properties.exposure,
1919 camera_properties_obj=camera_properties
1923 radiation_wrapper.addRadiationCameraVec3(
1927 validated_position.x, validated_position.y, validated_position.z,
1928 validated_direction.x, validated_direction.y, validated_direction.z,
1929 camera_properties.to_array(),
1931 camera_properties.exposure,
1932 camera_properties_obj=camera_properties
1935 except Exception
as e:
1938 @require_plugin('radiation', 'add SIF camera')
1939 def addSIFCamera(self, camera_label: str, emission_band_labels: List[str], position,
1940 lookat_or_direction, camera_properties=
None, antialiasing_samples: int = 100):
1942 Add a solar-induced chlorophyll fluorescence (SIF) camera.
1944 Each band in ``emission_band_labels`` must already exist (added via
1945 :meth:`addRadiationBand`); those bands are flagged internally as SIF-emitting and
1946 use the Fluspect-B leaf-fluorescence kernel for emission instead of Stefan-Boltzmann.
1947 Helios auto-creates internal radiation bands covering 400-750 nm at the resolution
1948 specified by ``camera_properties.excitation_bin_width_nm``.
1951 camera_label: Unique label for the camera.
1952 emission_band_labels: List of pre-existing radiation band labels to drive
1954 position: Camera position as a ``vec3``.
1955 lookat_or_direction: Either a ``vec3`` lookat point or a ``SphericalCoord``
1957 camera_properties: :class:`SIFCameraProperties` instance. If ``None`` defaults
1958 are used (10 nm excitation bins, no excitation scattering).
1959 antialiasing_samples: Antialiasing samples per pixel (>= 1, default 100).
1962 RadiationModelError: If the underlying SIF camera cannot be added (e.g.,
1963 an emission band was already bound to a different excitation bin width).
1964 NotImplementedError: If running against helios-core older than v1.3.72.
1966 from .wrappers
import URadiationModelWrapper
as radiation_wrapper
1967 from .wrappers.DataTypes
import SphericalCoord, vec3
1968 from .validation.plugins
import (
1969 validate_camera_label, validate_band_labels_list, validate_antialiasing_samples
1972 validated_label = validate_camera_label(camera_label,
"camera_label",
"addSIFCamera")
1973 validated_bands = validate_band_labels_list(emission_band_labels,
"emission_band_labels",
"addSIFCamera")
1974 validated_samples = validate_antialiasing_samples(antialiasing_samples,
"antialiasing_samples",
"addSIFCamera")
1976 if not isinstance(position, vec3):
1977 raise TypeError(
"position must be a vec3 object. Use vec3(x, y, z) to create one.")
1979 if not isinstance(lookat_or_direction, (vec3, SphericalCoord)):
1980 raise TypeError(
"lookat_or_direction must be a vec3 or SphericalCoord object.")
1982 if camera_properties
is None:
1984 elif not isinstance(camera_properties, SIFCameraProperties):
1986 "camera_properties must be a SIFCameraProperties instance "
1987 "(use SIFCameraProperties(...) — not the plain CameraProperties)."
1992 if isinstance(lookat_or_direction, SphericalCoord):
1995 direction_coords = lookat_or_direction.to_list()
1996 if len(direction_coords) < 4:
1997 raise ValueError(
"SphericalCoord must expose radius, elevation, zenith, and azimuth")
1998 radius, elevation, azimuth = direction_coords[0], direction_coords[1], direction_coords[3]
1999 radiation_wrapper.addSIFCameraSpherical(
2003 position.x, position.y, position.z,
2004 radius, elevation, azimuth,
2005 camera_properties.to_array(),
2006 camera_properties.excitation_bin_width_nm,
2007 camera_properties.excitation_scattering_depth,
2009 camera_properties_obj=camera_properties,
2012 radiation_wrapper.addSIFCameraVec3(
2016 position.x, position.y, position.z,
2017 lookat_or_direction.x, lookat_or_direction.y, lookat_or_direction.z,
2018 camera_properties.to_array(),
2019 camera_properties.excitation_bin_width_nm,
2020 camera_properties.excitation_scattering_depth,
2022 camera_properties_obj=camera_properties,
2024 except Exception
as e:
2027 @require_plugin('radiation', 'check SIF camera registration')
2030 Return True if the camera was registered via :meth:`addSIFCamera` (vs. ``addRadiationCamera``).
2032 from .wrappers
import URadiationModelWrapper
as radiation_wrapper
2033 if not isinstance(camera_label, str)
or not camera_label.strip():
2034 raise ValueError(
"Camera label must be a non-empty string")
2036 return radiation_wrapper.isSIFCamera(self.
radiation_model, camera_label)
2038 @require_plugin('radiation', 'enable camera flux smoothing')
2041 Reconstruct camera images by interpolating outgoing flux across each facet.
2043 Averages each primitive's outgoing flux onto the mesh vertices it shares with its
2044 neighbours and interpolates it back across the facet, so a coarsely tessellated
2045 curved surface reads as a curve rather than as a set of flat panels. This applies
2046 to Tube, Sphere, Cone, Polymesh, Tile and AdaptiveTile objects. A Box, a Disk, and
2047 any primitive belonging to no object are left alone, since their faces are genuinely
2048 flat. A polymesh must carry the face table that :meth:`Context.loadOBJ` and
2049 :meth:`Context.loadPLY` retain; one assembled from loose primitives by
2050 :meth:`Context.addPolymeshObject` has no topology and is left alone.
2052 Smoothing is off by default and should be enabled deliberately: it changes what the
2053 camera reports. A pixel no longer carries the outgoing flux of the primitive behind
2054 it, but a blend of that primitive's value with its neighbours', so pixel values no
2055 longer correspond exactly to the ``radiation_flux_*`` primitive data. The radiation
2056 solve, those primitive data values, and the pixel-label and depth images are all
2060 crease_angle_degrees: Angle between adjacent facet normals above which a shared
2061 edge is treated as a hard crease, so flux is not averaged across it. Applies
2062 to polymesh objects only; the curved object types are smooth by construction
2063 and are never creased. Must be between 0 and 180 degrees. Defaults to 30.
2066 RadiationModelError: If the crease angle is out of range or the call fails
2067 RuntimeError: If the native library predates helios-core v1.3.84
2070 If geometry has already been built, this re-uploads it and rebuilds the
2071 acceleration structure, which is not cheap. Prefer calling it before
2072 :meth:`updateGeometry`.
2075 >>> with RadiationModel(context) as radiation:
2076 ... radiation.enableCameraFluxSmoothing(crease_angle_degrees=45.0)
2077 ... radiation.updateGeometry()
2079 from .wrappers
import URadiationModelWrapper
as radiation_wrapper
2080 if not isinstance(crease_angle_degrees, (int, float))
or isinstance(crease_angle_degrees, bool):
2082 f
"crease_angle_degrees must be a number, got {type(crease_angle_degrees).__name__}"
2086 radiation_wrapper.enableCameraFluxSmoothing(self.
radiation_model, float(crease_angle_degrees))
2087 except RuntimeError:
2089 except Exception
as e:
2092 @require_plugin('radiation', 'disable camera flux smoothing')
2095 Reconstruct camera images by holding the outgoing flux constant across each facet.
2097 This is the default. Each pixel reports the outgoing flux of the primitive behind it.
2100 RadiationModelError: If the call fails
2101 RuntimeError: If the native library predates helios-core v1.3.84
2104 If geometry has already been built, this re-uploads it and rebuilds the
2105 acceleration structure.
2107 from .wrappers
import URadiationModelWrapper
as radiation_wrapper
2111 except RuntimeError:
2113 except Exception
as e:
2116 @require_plugin('radiation', 'query camera flux smoothing state')
2119 Return True if camera images are reconstructed by interpolating flux across each facet.
2122 RuntimeError: If the native library predates helios-core v1.3.84
2124 from .wrappers
import URadiationModelWrapper
as radiation_wrapper
2126 return radiation_wrapper.isCameraFluxSmoothingEnabled(self.
radiation_model)
2128 @require_plugin('radiation', 'query camera flux smoothing crease angle')
2131 Return the crease angle in degrees used by camera flux smoothing.
2133 This is the value last passed to :meth:`enableCameraFluxSmoothing`, or 30.0 if it
2134 has never been called.
2137 RuntimeError: If the native library predates helios-core v1.3.84
2139 from .wrappers
import URadiationModelWrapper
as radiation_wrapper
2141 return radiation_wrapper.getCameraFluxSmoothingCreaseAngle(self.
radiation_model)
2143 @require_plugin('radiation', 'manage camera position')
2146 Set camera position.
2148 Allows dynamic camera repositioning during simulation, useful for
2149 time-series captures or multi-view imaging.
2152 camera_label: Camera label string
2153 position: Camera position as vec3 or list [x, y, z]
2156 >>> radiation.setCameraPosition("cam1", [0, 0, 10])
2157 >>> from pyhelios.types import vec3
2158 >>> radiation.setCameraPosition("cam1", vec3(5, 5, 10))
2160 if not isinstance(camera_label, str)
or not camera_label.strip():
2161 raise ValueError(
"Camera label must be a non-empty string")
2162 validate_position_like(position,
"position",
"setCameraPosition")
2164 radiation_wrapper.setCameraPosition(self.
radiation_model, camera_label, position)
2165 logger.debug(f
"Updated camera '{camera_label}' position")
2167 @require_plugin('radiation', 'query camera position')
2170 Get camera position.
2173 camera_label: Camera label string
2176 vec3 position of the camera
2179 >>> position = radiation.getCameraPosition("cam1")
2180 >>> print(f"Camera at: {position}")
2182 if not isinstance(camera_label, str)
or not camera_label.strip():
2183 raise ValueError(
"Camera label must be a non-empty string")
2185 position_list = radiation_wrapper.getCameraPosition(self.
radiation_model, camera_label)
2186 from .wrappers.DataTypes
import vec3
2187 return vec3(position_list[0], position_list[1], position_list[2])
2189 @require_plugin('radiation', 'manage camera lookat')
2192 Set camera lookat point.
2195 camera_label: Camera label string
2196 lookat: Lookat point as vec3 or list [x, y, z]
2199 >>> radiation.setCameraLookat("cam1", [0, 0, 0])
2201 if not isinstance(camera_label, str)
or not camera_label.strip():
2202 raise ValueError(
"Camera label must be a non-empty string")
2203 validate_position_like(lookat,
"lookat",
"setCameraLookat")
2205 radiation_wrapper.setCameraLookat(self.
radiation_model, camera_label, lookat)
2206 logger.debug(f
"Updated camera '{camera_label}' lookat point")
2208 @require_plugin('radiation', 'query camera lookat')
2211 Get camera lookat point.
2214 camera_label: Camera label string
2220 >>> lookat = radiation.getCameraLookat("cam1")
2221 >>> print(f"Camera looking at: {lookat}")
2223 if not isinstance(camera_label, str)
or not camera_label.strip():
2224 raise ValueError(
"Camera label must be a non-empty string")
2226 lookat_list = radiation_wrapper.getCameraLookat(self.
radiation_model, camera_label)
2227 from .wrappers.DataTypes
import vec3
2228 return vec3(lookat_list[0], lookat_list[1], lookat_list[2])
2230 @require_plugin('radiation', 'manage camera orientation')
2233 Set camera orientation.
2236 camera_label: Camera label string
2237 direction: View direction as vec3, SphericalCoord, or list [x, y, z]
2240 >>> radiation.setCameraOrientation("cam1", [0, 0, 1])
2241 >>> from pyhelios.types import SphericalCoord
2242 >>> radiation.setCameraOrientation("cam1", SphericalCoord(1.0, 45.0, 90.0))
2244 if not isinstance(camera_label, str)
or not camera_label.strip():
2245 raise ValueError(
"Camera label must be a non-empty string")
2246 validate_direction_like(direction,
"direction",
"setCameraOrientation")
2248 radiation_wrapper.setCameraOrientation(self.
radiation_model, camera_label, direction)
2249 logger.debug(f
"Updated camera '{camera_label}' orientation")
2251 @require_plugin('radiation', 'query camera orientation')
2254 Get camera orientation.
2257 camera_label: Camera label string
2260 SphericalCoord orientation [radius, elevation, azimuth]
2263 >>> orientation = radiation.getCameraOrientation("cam1")
2264 >>> print(f"Camera orientation: {orientation}")
2266 if not isinstance(camera_label, str)
or not camera_label.strip():
2267 raise ValueError(
"Camera label must be a non-empty string")
2269 orientation_list = radiation_wrapper.getCameraOrientation(self.
radiation_model, camera_label)
2270 from .wrappers.DataTypes
import SphericalCoord
2271 return SphericalCoord(orientation_list[0], orientation_list[1], orientation_list[2])
2273 @require_plugin('radiation', 'query cameras')
2276 Get all camera labels.
2279 List of all camera label strings
2282 >>> cameras = radiation.getAllCameraLabels()
2283 >>> print(f"Available cameras: {cameras}")
2288 @require_plugin('radiation', 'configure camera spectral response')
2291 Set camera spectral response from global data.
2294 camera_label: Camera label
2295 band_label: Band label
2296 global_data: Global data label for spectral response curve
2299 >>> radiation.setCameraSpectralResponse("cam1", "red", "sensor_red_response")
2301 if not isinstance(camera_label, str)
or not camera_label.strip():
2302 raise ValueError(
"Camera label must be a non-empty string")
2303 validate_band_label(band_label,
"band_label",
"setCameraSpectralResponse")
2304 if not isinstance(global_data, str)
or not global_data.strip():
2305 raise ValueError(
"Global data label must be a non-empty string")
2308 radiation_wrapper.setCameraSpectralResponse(self.
radiation_model, camera_label, band_label, global_data)
2309 logger.debug(f
"Set spectral response for camera '{camera_label}', band '{band_label}'")
2311 camera_label, [band_label],
"setCameraSpectralResponse")
2313 @require_plugin('radiation', 'configure camera from library')
2316 Set camera spectral response from standard camera library.
2318 Uses pre-defined spectral response curves for common cameras.
2321 camera_label: Camera label
2322 camera_library_name: Standard camera name (e.g., "iPhone13", "NikonD850", "CanonEOS5D")
2325 >>> radiation.setCameraSpectralResponseFromLibrary("cam1", "iPhone13")
2327 if not isinstance(camera_label, str)
or not camera_label.strip():
2328 raise ValueError(
"Camera label must be a non-empty string")
2329 if not isinstance(camera_library_name, str)
or not camera_library_name.strip():
2330 raise ValueError(
"Camera library name must be a non-empty string")
2333 radiation_wrapper.setCameraSpectralResponseFromLibrary(self.
radiation_model, camera_label, camera_library_name)
2334 logger.debug(f
"Set camera '{camera_label}' response from library: {camera_library_name}")
2336 @require_plugin('radiation', 'get camera pixel data')
2339 Get camera pixel data for specific band.
2341 Retrieves raw pixel values for programmatic access and analysis.
2344 camera_label: Camera label
2345 band_label: Band label
2348 List of pixel values
2351 >>> pixels = radiation.getCameraPixelData("cam1", "red")
2352 >>> print(f"Mean pixel value: {sum(pixels)/len(pixels)}")
2354 if not isinstance(camera_label, str)
or not camera_label.strip():
2355 raise ValueError(
"Camera label must be a non-empty string")
2356 validate_band_label(band_label,
"band_label",
"getCameraPixelData")
2359 return radiation_wrapper.getCameraPixelData(self.
radiation_model, camera_label, band_label)
2361 @require_plugin('radiation', 'set camera pixel data')
2362 def setCameraPixelData(self, camera_label: str, band_label: str, pixel_data: List[float]):
2364 Set camera pixel data for specific band.
2366 Allows programmatic modification of pixel values.
2369 camera_label: Camera label
2370 band_label: Band label
2371 pixel_data: List of pixel values
2374 >>> pixels = radiation.getCameraPixelData("cam1", "red")
2375 >>> modified_pixels = [p * 1.2 for p in pixels] # Brighten by 20%
2376 >>> radiation.setCameraPixelData("cam1", "red", modified_pixels)
2378 if not isinstance(camera_label, str)
or not camera_label.strip():
2379 raise ValueError(
"Camera label must be a non-empty string")
2380 validate_band_label(band_label,
"band_label",
"setCameraPixelData")
2381 if not isinstance(pixel_data, (list, tuple)):
2382 raise ValueError(
"Pixel data must be a list or tuple")
2385 radiation_wrapper.setCameraPixelData(self.
radiation_model, camera_label, band_label, pixel_data)
2386 logger.debug(f
"Set pixel data for camera '{camera_label}', band '{band_label}': {len(pixel_data)} pixels")
2392 @require_plugin('radiation', 'add camera from library')
2394 position, lookat, antialiasing_samples: int = 1,
2395 band_labels: Optional[List[str]] =
None):
2397 Add radiation camera loading all properties from camera library.
2399 Loads camera intrinsic parameters (resolution, FOV, sensor size) and spectral
2400 response data from the camera library XML file. This is the recommended way to
2401 create realistic cameras with proper spectral responses.
2404 camera_label: Label for the camera instance
2405 library_camera_label: Label of camera in library (e.g., "Canon_20D", "iPhone11", "NikonD700")
2406 position: Camera position as vec3 or (x, y, z) tuple
2407 lookat: Lookat point as vec3 or (x, y, z) tuple
2408 antialiasing_samples: Number of ray samples per pixel. Default: 1
2409 band_labels: Optional custom band labels. If None, uses library defaults.
2412 RadiationModelError: If operation fails
2413 ValueError: If parameters are invalid
2416 Available cameras in plugins/radiation/camera_library/camera_library.xml include:
2417 - Canon_20D, Nikon_D700, Nikon_D50
2418 - iPhone11, iPhone12ProMAX
2419 - Additional cameras available in library
2422 >>> radiation.addRadiationCameraFromLibrary(
2423 ... camera_label="cam1",
2424 ... library_camera_label="iPhone11",
2425 ... position=(0, -5, 1),
2426 ... lookat=(0, 0, 0.5),
2427 ... antialiasing_samples=10
2430 validate_band_label(camera_label,
"camera_label",
"addRadiationCameraFromLibrary")
2431 validate_position_like(position,
"position",
"addRadiationCameraFromLibrary")
2432 validate_position_like(lookat,
"lookat",
"addRadiationCameraFromLibrary")
2436 radiation_wrapper.addRadiationCameraFromLibrary(
2438 position, lookat, antialiasing_samples, band_labels
2440 logger.info(f
"Added camera '{camera_label}' from library '{library_camera_label}'")
2444 camera_label, band_labels,
"addRadiationCameraFromLibrary")
2445 except Exception
as e:
2448 @require_plugin('radiation', 'update camera parameters')
2451 Update camera parameters for an existing camera.
2453 Allows modification of camera properties after creation while preserving
2454 position, lookat direction, and spectral band configuration.
2457 camera_label: Label for the camera to update
2458 camera_properties: CameraProperties instance with new parameters
2461 RadiationModelError: If operation fails or camera doesn't exist
2462 ValueError: If parameters are invalid
2465 FOV_aspect_ratio is automatically recalculated from camera_resolution.
2466 Camera position and lookat are preserved.
2469 **Changing camera_resolution discards the camera's existing image data.**
2470 The per-pixel buffers are sized to the resolution the camera was rendered
2471 at, so helios-core v1.3.79+ clears them rather than reinterpret them at the
2472 new size. The camera must be re-rendered with :meth:`runBand` before its
2473 image can be written again; :meth:`writeCameraImage` will otherwise report
2474 that the camera has no rendered pixel data. Changing any other parameter
2475 leaves existing image data intact.
2478 >>> props = CameraProperties(
2479 ... camera_resolution=(1920, 1080),
2481 ... lens_focal_length=0.085 # 85mm lens
2483 >>> radiation.updateCameraParameters("cam1", props)
2484 >>> # Resolution changed, so re-render before writing an image.
2485 >>> radiation.runBand(["red", "green", "blue"])
2486 >>> radiation.writeCameraImage("cam1", ["red", "green", "blue"], "out")
2488 validate_band_label(camera_label,
"camera_label",
"updateCameraParameters")
2490 if not isinstance(camera_properties, CameraProperties):
2491 raise ValueError(
"camera_properties must be a CameraProperties instance")
2495 radiation_wrapper.updateCameraParameters(self.
radiation_model, camera_label, camera_properties)
2496 logger.debug(f
"Updated parameters for camera '{camera_label}'")
2497 except Exception
as e:
2500 @require_plugin('radiation', 'enable camera metadata')
2503 Enable automatic JSON metadata file writing for camera(s).
2505 When enabled, writeCameraImage() automatically creates a JSON metadata file
2506 alongside the image containing comprehensive camera and scene information.
2509 camera_labels: Single camera label (str) or list of camera labels (List[str])
2512 RadiationModelError: If operation fails
2513 ValueError: If parameters are invalid
2517 - Camera properties (model, lens, sensor specs)
2518 - Geographic location (latitude, longitude)
2519 - Acquisition settings (date, time, exposure, white balance)
2520 - Agronomic data (plant species, heights, phenology stages)
2523 >>> # Enable for single camera
2524 >>> radiation.enableCameraMetadata("cam1")
2526 >>> # Enable for multiple cameras
2527 >>> radiation.enableCameraMetadata(["cam1", "cam2", "cam3"])
2531 radiation_wrapper.enableCameraMetadata(self.
radiation_model, camera_labels)
2532 if isinstance(camera_labels, str):
2533 logger.info(f
"Enabled metadata for camera '{camera_labels}'")
2535 logger.info(f
"Enabled metadata for {len(camera_labels)} cameras")
2536 except Exception
as e:
2539 @require_plugin('radiation', 'write camera images')
2540 def writeCameraImage(self, camera: str, bands: List[str], imagefile_base: str,
2541 image_path: str =
"./", frame: int = -1,
2542 flux_to_pixel_conversion: float = 1.0) -> str:
2544 Write camera image to file and return output filename.
2547 camera: Camera label
2548 bands: List of band labels to include in the image
2549 imagefile_base: Base filename for output
2550 image_path: Output directory path (default: current directory)
2551 frame: Frame number to write (-1 for all frames)
2552 flux_to_pixel_conversion: Conversion factor from flux to pixel values
2555 Output filename string
2558 RadiationModelError: If camera image writing fails
2559 TypeError: If parameters have incorrect types
2562 helios-core v1.3.79+ reports a failed write by returning an empty
2563 filename rather than raising. PyHelios converts that into a
2564 RadiationModelError so a write that produced no file can never be
2565 mistaken for a success.
2568 if not isinstance(camera, str)
or not camera.strip():
2569 raise TypeError(
"Camera label must be a non-empty string")
2570 if not isinstance(bands, list)
or not bands:
2571 raise TypeError(
"Bands must be a non-empty list of strings")
2572 if not all(isinstance(band, str)
and band.strip()
for band
in bands):
2573 raise TypeError(
"All band labels must be non-empty strings")
2574 if not isinstance(imagefile_base, str)
or not imagefile_base.strip():
2575 raise TypeError(
"Image file base must be a non-empty string")
2576 if not isinstance(image_path, str):
2577 raise TypeError(
"Image path must be a string")
2578 if not isinstance(frame, int):
2579 raise TypeError(
"Frame must be an integer")
2580 if not isinstance(flux_to_pixel_conversion, (int, float))
or flux_to_pixel_conversion <= 0:
2581 raise TypeError(
"Flux to pixel conversion must be a positive number")
2585 filename = radiation_wrapper.writeCameraImage(
2587 image_path, frame, flux_to_pixel_conversion)
2590 "write camera image")
2591 logger.info(f
"Camera image written to: {filename}")
2594 @require_plugin('radiation', 'write normalized camera images')
2596 image_path: str =
"./", frame: int = -1) -> str:
2598 Write normalized camera image to file and return output filename.
2601 camera: Camera label
2602 bands: List of band labels to include in the image
2603 imagefile_base: Base filename for output
2604 image_path: Output directory path (default: current directory)
2605 frame: Frame number to write (-1 for all frames)
2608 Output filename string
2611 RadiationModelError: If normalized camera image writing fails
2612 TypeError: If parameters have incorrect types
2615 helios-core v1.3.79+ reports a failed write by returning an empty
2616 filename rather than raising. PyHelios converts that into a
2617 RadiationModelError so a write that produced no file can never be
2618 mistaken for a success.
2621 if not isinstance(camera, str)
or not camera.strip():
2622 raise TypeError(
"Camera label must be a non-empty string")
2623 if not isinstance(bands, list)
or not bands:
2624 raise TypeError(
"Bands must be a non-empty list of strings")
2625 if not all(isinstance(band, str)
and band.strip()
for band
in bands):
2626 raise TypeError(
"All band labels must be non-empty strings")
2627 if not isinstance(imagefile_base, str)
or not imagefile_base.strip():
2628 raise TypeError(
"Image file base must be a non-empty string")
2629 if not isinstance(image_path, str):
2630 raise TypeError(
"Image path must be a string")
2631 if not isinstance(frame, int):
2632 raise TypeError(
"Frame must be an integer")
2636 filename = radiation_wrapper.writeNormCameraImage(
2637 self.
radiation_model, camera, bands, imagefile_base, image_path, frame)
2640 "write normalized camera image")
2641 logger.info(f
"Normalized camera image written to: {filename}")
2644 @require_plugin('radiation', 'write camera image data')
2646 image_path: str =
"./", frame: int = -1):
2648 Write camera image data to file (ASCII format).
2651 camera: Camera label
2653 imagefile_base: Base filename for output
2654 image_path: Output directory path (default: current directory)
2655 frame: Frame number to write (-1 for all frames)
2658 RadiationModelError: If camera image data writing fails
2659 TypeError: If parameters have incorrect types
2662 if not isinstance(camera, str)
or not camera.strip():
2663 raise TypeError(
"Camera label must be a non-empty string")
2664 if not isinstance(band, str)
or not band.strip():
2665 raise TypeError(
"Band label must be a non-empty string")
2666 if not isinstance(imagefile_base, str)
or not imagefile_base.strip():
2667 raise TypeError(
"Image file base must be a non-empty string")
2668 if not isinstance(image_path, str):
2669 raise TypeError(
"Image path must be a string")
2670 if not isinstance(frame, int):
2671 raise TypeError(
"Frame must be an integer")
2674 radiation_wrapper.writeCameraImageData(
2677 logger.info(f
"Camera image data written for camera {camera}, band {band}")
2679 @require_plugin('radiation', 'write primitive data label map')
2681 image_path: str =
"./", frame: int = -1, padvalue: float = float(
'nan')):
2683 Write a per-pixel primitive-data label map for a camera to a text file.
2685 For each camera pixel, writes the value of ``primitive_data_label`` on the primitive
2686 seen at that pixel. Pixels that hit no geometry (or a primitive lacking the data) are
2687 written as ``padvalue`` (NaN by default). The primitive data must be of type
2688 float, double, uint, or int. The radiation model must have been run
2689 (``updateGeometry`` + ``runBand``) so that per-pixel primitive labels exist.
2691 The output file is written row-by-row (one line per image row), so it loads directly
2692 into a 2D ``(height, width)`` array. See :meth:`getPrimitiveDataLabelMap` for a
2693 convenience that returns a NumPy array instead of a file on disk.
2695 Output filename: ``{camera}_{imagefile_base}.txt`` when ``frame < 0`` (default), or
2696 ``{camera}_{imagefile_base}_{frame:05d}.txt`` when ``frame >= 0``.
2699 camera: Camera label
2700 primitive_data_label: Primitive data label to map (float/double/uint/int)
2701 imagefile_base: Base filename for output
2702 image_path: Output directory path (default: current directory)
2703 frame: Frame number to write (-1 to omit the frame suffix)
2704 padvalue: Value written for empty/background pixels (default: NaN)
2707 RadiationModelError: If the label map writing fails
2708 TypeError: If parameters have incorrect types
2711 >>> radiation.writePrimitiveDataLabelMap(
2712 ... camera="main_cam", primitive_data_label="leaf_id",
2713 ... imagefile_base="leaf_labels", image_path="./output")
2716 if not isinstance(camera, str)
or not camera.strip():
2717 raise TypeError(
"Camera label must be a non-empty string")
2718 if not isinstance(primitive_data_label, str)
or not primitive_data_label.strip():
2719 raise TypeError(
"Primitive data label must be a non-empty string")
2720 if not isinstance(imagefile_base, str)
or not imagefile_base.strip():
2721 raise TypeError(
"Image file base must be a non-empty string")
2722 if not isinstance(image_path, str):
2723 raise TypeError(
"Image path must be a string")
2724 if not isinstance(frame, int):
2725 raise TypeError(
"Frame must be an integer")
2726 if not isinstance(padvalue, (int, float))
or isinstance(padvalue, bool):
2727 raise TypeError(
"Pad value must be a numeric type")
2730 radiation_wrapper.writePrimitiveDataLabelMap(
2732 image_path, frame, float(padvalue))
2734 logger.info(f
"Primitive data label map written for camera {camera}, label {primitive_data_label}")
2736 @require_plugin('radiation', 'write object data label map')
2738 image_path: str =
"./", frame: int = -1, padvalue: float = float(
'nan')):
2740 Write a per-pixel object-data label map for a camera to a text file.
2742 Identical to :meth:`writePrimitiveDataLabelMap` but maps the value of an object-data
2743 label (compound-object data) rather than primitive data. The object data must be of
2744 type float, double, uint, or int. The radiation model must have been run
2745 (``updateGeometry`` + ``runBand``) so that per-pixel labels exist.
2747 Output filename: ``{camera}_{imagefile_base}.txt`` when ``frame < 0`` (default), or
2748 ``{camera}_{imagefile_base}_{frame:05d}.txt`` when ``frame >= 0``.
2751 camera: Camera label
2752 object_data_label: Object data label to map (float/double/uint/int)
2753 imagefile_base: Base filename for output
2754 image_path: Output directory path (default: current directory)
2755 frame: Frame number to write (-1 to omit the frame suffix)
2756 padvalue: Value written for empty/background pixels (default: NaN)
2759 RadiationModelError: If the label map writing fails
2760 TypeError: If parameters have incorrect types
2763 if not isinstance(camera, str)
or not camera.strip():
2764 raise TypeError(
"Camera label must be a non-empty string")
2765 if not isinstance(object_data_label, str)
or not object_data_label.strip():
2766 raise TypeError(
"Object data label must be a non-empty string")
2767 if not isinstance(imagefile_base, str)
or not imagefile_base.strip():
2768 raise TypeError(
"Image file base must be a non-empty string")
2769 if not isinstance(image_path, str):
2770 raise TypeError(
"Image path must be a string")
2771 if not isinstance(frame, int):
2772 raise TypeError(
"Frame must be an integer")
2773 if not isinstance(padvalue, (int, float))
or isinstance(padvalue, bool):
2774 raise TypeError(
"Pad value must be a numeric type")
2777 radiation_wrapper.writeObjectDataLabelMap(
2779 image_path, frame, float(padvalue))
2781 logger.info(f
"Object data label map written for camera {camera}, label {object_data_label}")
2783 @require_plugin('radiation', 'read primitive data label map')
2785 padvalue: float = float(
'nan')) ->
'np.ndarray':
2787 Return a per-pixel primitive-data label map for a camera as a NumPy array.
2789 Convenience wrapper around :meth:`writePrimitiveDataLabelMap` for the common use case
2790 of per-pixel masking in Python: the label map is written to a temporary file, loaded
2791 with ``numpy.loadtxt``, and returned as a 2D array. The file does not persist.
2794 camera: Camera label
2795 primitive_data_label: Primitive data label to map (float/double/uint/int)
2796 padvalue: Value used for empty/background pixels (default: NaN)
2799 2D NumPy array of shape ``(height, width)`` (row-major) holding the primitive-data
2800 value at each pixel, with ``padvalue`` (NaN by default) where no labelled geometry
2804 RadiationModelError: If the label map generation fails
2805 TypeError: If parameters have incorrect types
2808 >>> labels = radiation.getPrimitiveDataLabelMap("main_cam", "leaf_id")
2809 >>> mask = labels == 3 # per-pixel mask for primitive label 3
2810 >>> background = np.isnan(labels)
2812 with tempfile.TemporaryDirectory()
as tmpdir:
2813 imagefile_base =
"labelmap"
2815 camera, primitive_data_label, imagefile_base,
2816 image_path=os.path.join(tmpdir,
""), frame=-1, padvalue=padvalue)
2818 filepath = os.path.join(tmpdir, f
"{camera}_{imagefile_base}.txt")
2819 labels = np.loadtxt(filepath)
2822 if labels.ndim == 1:
2823 labels = labels.reshape(1, -1)
2826 @require_plugin('radiation', 'read object data label map')
2828 padvalue: float = float(
'nan')) ->
'np.ndarray':
2830 Return a per-pixel object-data label map for a camera as a NumPy array.
2832 Convenience wrapper around :meth:`writeObjectDataLabelMap`; see
2833 :meth:`getPrimitiveDataLabelMap` for behaviour. The label map is written to a temporary
2834 file, loaded with ``numpy.loadtxt``, and returned as a 2D ``(height, width)`` array with
2835 ``padvalue`` (NaN by default) for background pixels. The file does not persist.
2838 camera: Camera label
2839 object_data_label: Object data label to map (float/double/uint/int)
2840 padvalue: Value used for empty/background pixels (default: NaN)
2843 2D NumPy array of shape ``(height, width)`` (row-major).
2846 RadiationModelError: If the label map generation fails
2847 TypeError: If parameters have incorrect types
2849 with tempfile.TemporaryDirectory()
as tmpdir:
2850 imagefile_base =
"labelmap"
2852 camera, object_data_label, imagefile_base,
2853 image_path=os.path.join(tmpdir,
""), frame=-1, padvalue=padvalue)
2854 filepath = os.path.join(tmpdir, f
"{camera}_{imagefile_base}.txt")
2855 labels = np.loadtxt(filepath)
2857 if labels.ndim == 1:
2858 labels = labels.reshape(1, -1)
2861 @require_plugin('radiation', 'write image bounding boxes')
2863 primitive_data_labels=
None, object_data_labels=
None,
2864 object_class_ids=
None, image_file: str =
"",
2865 classes_txt_file: str =
"classes.txt",
2866 image_path: str =
"./"):
2868 Write image bounding boxes for object detection training.
2870 Supports both single and multiple data labels. Either provide primitive_data_labels
2871 or object_data_labels, not both.
2874 camera_label: Camera label
2875 primitive_data_labels: Single primitive data label (str) or list of primitive data labels
2876 object_data_labels: Single object data label (str) or list of object data labels
2877 object_class_ids: Single class ID (int) or list of class IDs (must match data labels)
2878 image_file: Image filename
2879 classes_txt_file: Classes definition file (default: "classes.txt")
2880 image_path: Image output path (default: current directory)
2883 RadiationModelError: If bounding box writing fails
2884 TypeError: If parameters have incorrect types
2885 ValueError: If both primitive and object data labels are provided, or neither
2888 if primitive_data_labels
is not None and object_data_labels
is not None:
2889 raise ValueError(
"Cannot specify both primitive_data_labels and object_data_labels")
2890 if primitive_data_labels
is None and object_data_labels
is None:
2891 raise ValueError(
"Must specify either primitive_data_labels or object_data_labels")
2894 if not isinstance(camera_label, str)
or not camera_label.strip():
2895 raise TypeError(
"Camera label must be a non-empty string")
2896 if not isinstance(image_file, str)
or not image_file.strip():
2897 raise TypeError(
"Image file must be a non-empty string")
2898 if not isinstance(classes_txt_file, str):
2899 raise TypeError(
"Classes txt file must be a string")
2900 if not isinstance(image_path, str):
2901 raise TypeError(
"Image path must be a string")
2904 if primitive_data_labels
is not None:
2905 if isinstance(primitive_data_labels, str):
2907 if not isinstance(object_class_ids, int):
2908 raise TypeError(
"For single primitive data label, object_class_ids must be an integer")
2910 radiation_wrapper.writeImageBoundingBoxes(
2912 object_class_ids, image_file, classes_txt_file, image_path)
2913 logger.info(f
"Image bounding boxes written for primitive data: {primitive_data_labels}")
2915 elif isinstance(primitive_data_labels, list):
2917 if not isinstance(object_class_ids, list):
2918 raise TypeError(
"For multiple primitive data labels, object_class_ids must be a list")
2919 if len(primitive_data_labels) != len(object_class_ids):
2920 raise ValueError(
"primitive_data_labels and object_class_ids must have the same length")
2921 if not all(isinstance(lbl, str)
and lbl.strip()
for lbl
in primitive_data_labels):
2922 raise TypeError(
"All primitive data labels must be non-empty strings")
2923 if not all(isinstance(cid, int)
for cid
in object_class_ids):
2924 raise TypeError(
"All object class IDs must be integers")
2927 radiation_wrapper.writeImageBoundingBoxesVector(
2929 object_class_ids, image_file, classes_txt_file, image_path)
2930 logger.info(f
"Image bounding boxes written for {len(primitive_data_labels)} primitive data labels")
2932 raise TypeError(
"primitive_data_labels must be a string or list of strings")
2935 elif object_data_labels
is not None:
2936 if isinstance(object_data_labels, str):
2938 if not isinstance(object_class_ids, int):
2939 raise TypeError(
"For single object data label, object_class_ids must be an integer")
2941 radiation_wrapper.writeImageBoundingBoxes_ObjectData(
2943 object_class_ids, image_file, classes_txt_file, image_path)
2944 logger.info(f
"Image bounding boxes written for object data: {object_data_labels}")
2946 elif isinstance(object_data_labels, list):
2948 if not isinstance(object_class_ids, list):
2949 raise TypeError(
"For multiple object data labels, object_class_ids must be a list")
2950 if len(object_data_labels) != len(object_class_ids):
2951 raise ValueError(
"object_data_labels and object_class_ids must have the same length")
2952 if not all(isinstance(lbl, str)
and lbl.strip()
for lbl
in object_data_labels):
2953 raise TypeError(
"All object data labels must be non-empty strings")
2954 if not all(isinstance(cid, int)
for cid
in object_class_ids):
2955 raise TypeError(
"All object class IDs must be integers")
2958 radiation_wrapper.writeImageBoundingBoxes_ObjectDataVector(
2960 object_class_ids, image_file, classes_txt_file, image_path)
2961 logger.info(f
"Image bounding boxes written for {len(object_data_labels)} object data labels")
2963 raise TypeError(
"object_data_labels must be a string or list of strings")
2965 @require_plugin('radiation', 'write image segmentation masks')
2967 primitive_data_labels=
None, object_data_labels=
None,
2968 object_class_ids=
None, json_filename: str =
"",
2969 image_file: str =
"", append_file: bool =
False):
2971 Write image segmentation masks in COCO JSON format.
2973 Supports both single and multiple data labels. Either provide primitive_data_labels
2974 or object_data_labels, not both.
2977 camera_label: Camera label
2978 primitive_data_labels: Single primitive data label (str) or list of primitive data labels
2979 object_data_labels: Single object data label (str) or list of object data labels
2980 object_class_ids: Single class ID (int) or list of class IDs (must match data labels)
2981 json_filename: JSON output filename
2982 image_file: Image filename
2983 append_file: Whether to append to existing JSON file
2986 RadiationModelError: If segmentation mask writing fails
2987 TypeError: If parameters have incorrect types
2988 ValueError: If both primitive and object data labels are provided, or neither
2991 if primitive_data_labels
is not None and object_data_labels
is not None:
2992 raise ValueError(
"Cannot specify both primitive_data_labels and object_data_labels")
2993 if primitive_data_labels
is None and object_data_labels
is None:
2994 raise ValueError(
"Must specify either primitive_data_labels or object_data_labels")
2997 if not isinstance(camera_label, str)
or not camera_label.strip():
2998 raise TypeError(
"Camera label must be a non-empty string")
2999 if not isinstance(json_filename, str)
or not json_filename.strip():
3000 raise TypeError(
"JSON filename must be a non-empty string")
3001 if not isinstance(image_file, str)
or not image_file.strip():
3002 raise TypeError(
"Image file must be a non-empty string")
3003 if not isinstance(append_file, bool):
3004 raise TypeError(
"append_file must be a boolean")
3007 if primitive_data_labels
is not None:
3008 if isinstance(primitive_data_labels, str):
3010 if not isinstance(object_class_ids, int):
3011 raise TypeError(
"For single primitive data label, object_class_ids must be an integer")
3013 radiation_wrapper.writeImageSegmentationMasks(
3015 object_class_ids, json_filename, image_file, append_file)
3016 logger.info(f
"Image segmentation masks written for primitive data: {primitive_data_labels}")
3018 elif isinstance(primitive_data_labels, list):
3020 if not isinstance(object_class_ids, list):
3021 raise TypeError(
"For multiple primitive data labels, object_class_ids must be a list")
3022 if len(primitive_data_labels) != len(object_class_ids):
3023 raise ValueError(
"primitive_data_labels and object_class_ids must have the same length")
3024 if not all(isinstance(lbl, str)
and lbl.strip()
for lbl
in primitive_data_labels):
3025 raise TypeError(
"All primitive data labels must be non-empty strings")
3026 if not all(isinstance(cid, int)
for cid
in object_class_ids):
3027 raise TypeError(
"All object class IDs must be integers")
3030 radiation_wrapper.writeImageSegmentationMasksVector(
3032 object_class_ids, json_filename, image_file, append_file)
3033 logger.info(f
"Image segmentation masks written for {len(primitive_data_labels)} primitive data labels")
3035 raise TypeError(
"primitive_data_labels must be a string or list of strings")
3038 elif object_data_labels
is not None:
3039 if isinstance(object_data_labels, str):
3041 if not isinstance(object_class_ids, int):
3042 raise TypeError(
"For single object data label, object_class_ids must be an integer")
3044 radiation_wrapper.writeImageSegmentationMasks_ObjectData(
3046 object_class_ids, json_filename, image_file, append_file)
3047 logger.info(f
"Image segmentation masks written for object data: {object_data_labels}")
3049 elif isinstance(object_data_labels, list):
3051 if not isinstance(object_class_ids, list):
3052 raise TypeError(
"For multiple object data labels, object_class_ids must be a list")
3053 if len(object_data_labels) != len(object_class_ids):
3054 raise ValueError(
"object_data_labels and object_class_ids must have the same length")
3055 if not all(isinstance(lbl, str)
and lbl.strip()
for lbl
in object_data_labels):
3056 raise TypeError(
"All object data labels must be non-empty strings")
3057 if not all(isinstance(cid, int)
for cid
in object_class_ids):
3058 raise TypeError(
"All object class IDs must be integers")
3061 radiation_wrapper.writeImageSegmentationMasks_ObjectDataVector(
3063 object_class_ids, json_filename, image_file, append_file)
3064 logger.info(f
"Image segmentation masks written for {len(object_data_labels)} object data labels")
3066 raise TypeError(
"object_data_labels must be a string or list of strings")
3068 @require_plugin('radiation', 'auto-calibrate camera image')
3070 green_band_label: str, blue_band_label: str,
3071 output_file_path: str, print_quality_report: bool =
False,
3072 algorithm: str =
"MATRIX_3X3_AUTO",
3073 ccm_export_file_path: str =
"") -> str:
3075 Auto-calibrate camera image with color correction and return output filename.
3078 camera_label: Camera label
3079 red_band_label: Red band label
3080 green_band_label: Green band label
3081 blue_band_label: Blue band label
3082 output_file_path: Output file path
3083 print_quality_report: Whether to print quality report
3084 algorithm: Color correction algorithm ("DIAGONAL_ONLY", "MATRIX_3X3_AUTO", "MATRIX_3X3_FORCE")
3085 ccm_export_file_path: Path to export color correction matrix (optional)
3088 Output filename string
3091 RadiationModelError: If auto-calibration fails
3092 TypeError: If parameters have incorrect types
3093 ValueError: If algorithm is not valid
3096 if not isinstance(camera_label, str)
or not camera_label.strip():
3097 raise TypeError(
"Camera label must be a non-empty string")
3098 if not isinstance(red_band_label, str)
or not red_band_label.strip():
3099 raise TypeError(
"Red band label must be a non-empty string")
3100 if not isinstance(green_band_label, str)
or not green_band_label.strip():
3101 raise TypeError(
"Green band label must be a non-empty string")
3102 if not isinstance(blue_band_label, str)
or not blue_band_label.strip():
3103 raise TypeError(
"Blue band label must be a non-empty string")
3104 if not isinstance(output_file_path, str)
or not output_file_path.strip():
3105 raise TypeError(
"Output file path must be a non-empty string")
3106 if not isinstance(print_quality_report, bool):
3107 raise TypeError(
"print_quality_report must be a boolean")
3108 if not isinstance(ccm_export_file_path, str):
3109 raise TypeError(
"ccm_export_file_path must be a string")
3114 "MATRIX_3X3_AUTO": 1,
3115 "MATRIX_3X3_FORCE": 2
3118 if algorithm
not in algorithm_map:
3119 raise ValueError(f
"Invalid algorithm: {algorithm}. Must be one of: {list(algorithm_map.keys())}")
3121 algorithm_int = algorithm_map[algorithm]
3124 filename = radiation_wrapper.autoCalibrateCameraImage(
3126 blue_band_label, output_file_path, print_quality_report,
3127 algorithm_int, ccm_export_file_path)
3129 logger.info(f
"Auto-calibrated camera image written to: {filename}")
3133 """Get information about the radiation plugin."""
3134 registry = get_plugin_registry()
3135 return registry.get_plugin_capabilities(
'radiation')
3142 image_path: str =
"./", frame: int = -1):
3144 Write camera pixel data to an EXR file with lossless float compression.
3146 Preserves full floating-point precision unlike JPEG/PNG exports.
3149 camera: Camera label
3150 band: Band label (str) for single-band, or list of band labels for multi-band
3151 imagefile_base: Base filename for output
3152 image_path: Output directory path (default: current directory)
3153 frame: Frame number to append to filename (-1 to omit)
3156 RadiationModelError: If writing fails
3157 TypeError: If parameters have incorrect types
3159 if not isinstance(camera, str)
or not camera.strip():
3160 raise TypeError(
"Camera label must be a non-empty string")
3161 if not isinstance(imagefile_base, str)
or not imagefile_base.strip():
3162 raise TypeError(
"Image file base must be a non-empty string")
3163 if not isinstance(image_path, str):
3164 raise TypeError(
"Image path must be a string")
3165 if not isinstance(frame, int):
3166 raise TypeError(
"Frame must be an integer")
3168 if isinstance(band, str):
3169 if not band.strip():
3170 raise TypeError(
"Band label must be a non-empty string")
3171 elif isinstance(band, (list, tuple)):
3173 raise ValueError(
"Band list cannot be empty")
3175 if not isinstance(b, str)
or not b.strip():
3176 raise TypeError(
"Each band label must be a non-empty string")
3178 raise TypeError(
"band must be a string or list of strings")
3181 radiation_wrapper.writeCameraImageDataEXR(
3182 self.
radiation_model, camera, band, imagefile_base, image_path, frame)
3185 image_path: str =
"./", frame: int = -1):
3187 Write depth image data to an ASCII text file.
3190 camera_label: Camera label
3191 imagefile_base: Base filename for output
3192 image_path: Output directory path (default: current directory)
3193 frame: Frame number to append to filename (-1 to omit)
3196 RadiationModelError: If writing fails
3197 TypeError: If parameters have incorrect types
3199 if not isinstance(camera_label, str)
or not camera_label.strip():
3200 raise TypeError(
"Camera label must be a non-empty string")
3201 if not isinstance(imagefile_base, str)
or not imagefile_base.strip():
3202 raise TypeError(
"Image file base must be a non-empty string")
3203 if not isinstance(image_path, str):
3204 raise TypeError(
"Image path must be a string")
3205 if not isinstance(frame, int):
3206 raise TypeError(
"Frame must be an integer")
3209 radiation_wrapper.writeDepthImageData(
3210 self.
radiation_model, camera_label, imagefile_base, image_path, frame)
3213 image_path: str =
"./", frame: int = -1):
3215 Write depth image data to an EXR file with lossless float compression.
3217 Preserves full floating-point depth precision unlike ASCII or JPEG exports.
3220 camera_label: Camera label
3221 imagefile_base: Base filename for output
3222 image_path: Output directory path (default: current directory)
3223 frame: Frame number to append to filename (-1 to omit)
3226 RadiationModelError: If writing fails
3227 TypeError: If parameters have incorrect types
3229 if not isinstance(camera_label, str)
or not camera_label.strip():
3230 raise TypeError(
"Camera label must be a non-empty string")
3231 if not isinstance(imagefile_base, str)
or not imagefile_base.strip():
3232 raise TypeError(
"Image file base must be a non-empty string")
3233 if not isinstance(image_path, str):
3234 raise TypeError(
"Image path must be a string")
3235 if not isinstance(frame, int):
3236 raise TypeError(
"Frame must be an integer")
3239 radiation_wrapper.writeDepthImageDataEXR(
3240 self.
radiation_model, camera_label, imagefile_base, image_path, frame)
3243 image_path: str =
"./", frame: int = -1):
3245 Write normalized depth image as grayscale JPEG.
3247 Depth values are normalized to the range [0, max_depth] for visualization.
3250 camera_label: Camera label
3251 imagefile_base: Base filename for output
3252 max_depth: Maximum depth value for normalization (e.g., sky depth)
3253 image_path: Output directory path (default: current directory)
3254 frame: Frame number to append to filename (-1 to omit)
3257 RadiationModelError: If writing fails
3258 TypeError: If parameters have incorrect types
3259 ValueError: If max_depth is not positive
3261 if not isinstance(camera_label, str)
or not camera_label.strip():
3262 raise TypeError(
"Camera label must be a non-empty string")
3263 if not isinstance(imagefile_base, str)
or not imagefile_base.strip():
3264 raise TypeError(
"Image file base must be a non-empty string")
3265 if not isinstance(max_depth, (int, float)):
3266 raise TypeError(
"max_depth must be a number")
3268 raise ValueError(
"max_depth must be positive")
3269 if not isinstance(image_path, str):
3270 raise TypeError(
"Image path must be a string")
3271 if not isinstance(frame, int):
3272 raise TypeError(
"Frame must be an integer")
3275 radiation_wrapper.writeNormDepthImage(
3276 self.
radiation_model, camera_label, imagefile_base, float(max_depth), image_path, frame)
3284 Get the name of the active ray tracing backend.
3287 Backend name string (e.g., "OptiX 8.1", "Vulkan Compute")
3295 Probe whether any compiled-in GPU backend is available on this system.
3297 Probes backends in priority order (OptiX 8 -> OptiX 6 -> Vulkan) without
3298 constructing a full backend. Useful for checking GPU availability before
3299 creating a RadiationModel.
3301 This is the PyHelios equivalent of the native
3302 ``RadiationModel::isGPUBackendAvailable()``, which as of helios-core v1.3.79
3303 is a pure delegate to the same underlying probe.
3305 Returns False when ``HELIOS_NO_GPU`` is set to anything other than ``"0"``,
3306 whatever hardware is actually present -- see
3307 :meth:`gpuBackendsDisabledByEnvironment` to distinguish an environment veto
3308 from genuinely absent hardware.
3311 The probe runs at most once per process and the result is cached
3312 (helios-core v1.3.79+), so repeated calls are cheap and never re-enter
3313 the GPU driver. The flip side is that a driver which becomes usable
3314 after the first probe is not picked up until the process restarts, so a
3315 test that enables a device and re-probes in the same process still sees
3319 True if at least one GPU backend is available
3322 >>> if RadiationModel.probeAnyGPUBackend():
3323 ... with RadiationModel(context) as radiation:
3324 ... radiation.addRadiationBand("SW")
3326 return radiation_wrapper.probeAnyGPUBackend()
3331 Check whether GPU backends are vetoed by the ``HELIOS_NO_GPU`` environment variable.
3333 True when ``HELIOS_NO_GPU`` is set to any value other than ``"0"``. The veto
3334 makes a GPU-equipped machine behave exactly like one with no compatible
3335 hardware: :meth:`probeAnyGPUBackend` returns False and constructing a
3336 RadiationModel with the automatic backend fails. Requesting a backend by name
3339 Use this to tell an intentional veto apart from missing hardware when
3340 reporting why the GPU path is unavailable.
3343 The environment is read once and cached for the process lifetime, so
3344 changing ``HELIOS_NO_GPU`` after the first call has no effect.
3347 True if GPU backend probing is disabled by the environment
3350 RuntimeError: If the native library predates helios-core v1.3.79
3353 >>> if not RadiationModel.probeAnyGPUBackend():
3354 ... if RadiationModel.gpuBackendsDisabledByEnvironment():
3355 ... print("GPU disabled via HELIOS_NO_GPU")
3357 ... print("No compatible GPU found")
3359 return radiation_wrapper.gpuBackendsDisabledByEnvironment()