Source code for eradiate.scenes.measure._hemispherical_distant

from __future__ import annotations

import attrs
import drjit as dr
import mitsuba as mi
import numpy as np
import pint
import pinttr

from ._distant import DistantMeasure
from ... import frame, validators
from ..._config import config
from ...attrs import documented, parse_docs
from ...units import symbol
from ...units import unit_context_config as ucc
from ...units import unit_context_kernel as uck
from ...units import unit_registry as ureg
from ...warp import square_to_uniform_hemisphere


[docs] @parse_docs @attrs.define(eq=False, slots=False) class HemisphericalDistantMeasure(DistantMeasure): """ Hemispherical distant radiance measure scene element [``hdistant``, ``hemispherical_distant``]. This scene element records radiance leaving the scene in a hemisphere defined by its ``direction`` parameter. A distinctive feature of this measure is that it samples continuously the direction space instead of computing radiance values for a fixed set of directions, thus potentially capturing effects much harder to distinguish using *e.g.* the :class:`.MultiDistantMeasure` class. On the other side, features located at a precise angle will not be captured very well by this measure. This measure is useful to get a global view of leaving radiance patterns over a surface. Notes ----- * Setting the ``target`` parameter is required to get meaningful results. Experiment classes should take care of setting it appropriately. """ # -------------------------------------------------------------------------- # Fields and properties # -------------------------------------------------------------------------- azimuth_convention: frame.AzimuthConvention = documented( attrs.field( default=None, converter=lambda x: config.azimuth_convention if x is None else (frame.AzimuthConvention[x.upper()] if isinstance(x, str) else x), validator=attrs.validators.instance_of(frame.AzimuthConvention), ), doc="Azimuth convention. If ``None``, the global default configuration " "is used (see :class:`.EradiateConfig`).", type=".AzimuthConvention", init_type=".AzimuthConvention or str, optional", default="None", ) _film_resolution: tuple[int, int] = documented( attrs.field( default=(32, 32), validator=attrs.validators.deep_iterable( member_validator=attrs.validators.instance_of(int), iterable_validator=validators.has_len(2), ), ), doc="Film resolution as a (width, height) 2-tuple. " "If the height is set to 1, direction sampling will be restricted to a " "plane.", type="array-like", default="(32, 32)", ) orientation: pint.Quantity = documented( pinttr.field( default=ureg.Quantity(0.0, ureg.deg), validator=[validators.is_positive, pinttr.validators.has_compatible_units], units=ucc.deferred("angle"), ), doc="Azimuth angle defining the orientation of the sensor in the " "horizontal plane.\n" "\n" "Unit-enabled field (default: ucc['angle']).", type="float", default="0.0 deg", ) direction = documented( attrs.field( default=[0, 0, 1], converter=np.array, validator=validators.is_vector3, ), doc="A 3-vector orienting the hemisphere mapped by the measure.", type="array-like", default="[0, 0, 1]", ) @property def film_resolution(self): return self._film_resolution @property def viewing_angles(self) -> pint.Quantity: """ quantity: Viewing angles computed from stored film coordinates as a (width, height, 2) array. The last dimension is ordered as (zenith, azimuth). """ # Compute viewing angles at pixel locations # Angle computation must match the kernel plugin's direction sampling # routine angle_units = ucc.get("angle") # Compute pixel locations in film coordinates xs = ( np.linspace(0, 1, self.film_resolution[0], endpoint=False) + 0.5 / self.film_resolution[0] ) ys = ( np.linspace(0, 1, self.film_resolution[1], endpoint=False) + 0.5 / self.film_resolution[1] ) # Compute corresponding angles in specified azimuth convention xy = np.array([(x, y) for x in xs for y in ys]) angles = frame.direction_to_angles( square_to_uniform_hemisphere(xy), azimuth_convention=self.azimuth_convention, ).to(angle_units) # Reshape array to match film size on first 2 dimensions return angles.reshape((len(xs), len(ys), 2)) # -------------------------------------------------------------------------- # Kernel dictionary generation # -------------------------------------------------------------------------- @property def kernel_type(self) -> str: # Inherit docstring return "hdistant" @property def template(self) -> dict: # Inherit docstring result = super().template up = dr.normalize( dr.cross( mi.ScalarVector3f(self.direction), mi.ScalarVector3f( np.cos(self.orientation.m_as(ureg.rad)), np.sin(self.orientation.m_as(ureg.rad)), 0.0, ), ) ) result["to_world"] = mi.ScalarTransform4f.look_at( origin=[0.0, 0.0, 0.0], target=self.direction, up=up ) if self.target is not None: result["target"] = self.target.kernel_item() if self.ray_offset is not None: result["ray_offset"] = self.ray_offset.m_as(uck.get("length")) return result # -------------------------------------------------------------------------- # Post-processing information # -------------------------------------------------------------------------- @property def var(self) -> tuple[str, dict]: return "radiance", { "standard_name": "radiance", "long_name": "radiance", "units": symbol(uck.get("radiance")), }