# -*- coding: utf-8 -*-
# Copyright (c) 2020 Stephen Wasilewski, HSLU and EPFL
# =======================================================================
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
# =======================================================================
import sys
import warnings
import numpy as np
import functools
from raytools import translate
from raytools.evaluate.positionindex import PositionIndex
[docs]
class BaseMetricSet(object):
"""object for calculating metrics based on a view direction, and rays
consisting on direction, solid angle and luminance information
by encapsulating these calculations within a class, metrics with redundant
calculations can take advantage of cached results, for example dgp does
not need to recalculate illuminance when it has been directly requested.
all metrics can be accessed as properties (and are calculated just in time)
or the object can be called (no arguments) to return a np.array of all
metrics defined in "metricset"
Parameters
----------
vm: raytools.mapper.ViewMapper
the view direction
vec: np.array
(N, 3) directions of all rays in view
omega: np.array
(N,) solid angle of all rays in view
lum: np.array
(N,) luminance of all rays in view (multiplied by "scale")
metricset: list, optional
keys of metrics to return, same as property names
scale: float, optional
scalefactor for luminance
omega_as_view_area: bool, optional
take sum(omega) as view area. if false corrects omega to vm.area
warnings: bool, optional
if False, suppresses numpy warnings (zero div, etc...) when accessed
via __call__
kwargs:
additional arguments that may be required by additional properties
"""
#: available metrics (and the default return set)
defaultmetrics = ["illum", "avglum", "loggcr"]
allmetrics = defaultmetrics + ["gcr", "pwgcr", "logpwgcr", "density",
"avgraylum", "pwavglum", "maxlum"]
safe2sum = {"illum", "avglum", "density"}
def __init__(self, vec, omega, lum, vm, metricset=None, scale=179.,
omega_as_view_area=True, guth=True, warn=False,
**kwargs):
if metricset is not None:
self.check_metrics(metricset, True)
self.defaultmetrics = metricset
self.vm = vm
self.guth = guth
self.view_area = vm.area
self._correct_omega = not omega_as_view_area
v = translate.norm(vec)
if self.vm.aspect == 2:
self._vec = v
self._lum = lum
self.omega = omega
self.view_mask = slice(None)
else:
mask = self.vm.in_view(v, indices=False)
# find bright vectors that might overlap the edge of the view
if np.average(lum) > 0:
stray = np.argwhere(np.all([np.logical_not(mask),
np.isclose(self.vm.degrees(v),
vm.viewangle/2, atol=1),
lum/np.average(lum) > 10],
axis=0)).ravel()
else:
stray = []
if len(stray) > 0:
ost = np.atleast_1d(omega[stray])
vsts = v[stray].reshape(-1, 3)
ds = self.vm.radians(vsts)
a = self.vm.viewangle * np.pi / 360
a2 = np.square(a)
b2s = ost/np.pi
for s, b2, vst, d in zip(stray, b2s, vsts, ds):
b = np.sqrt(b2)
if a - b < d < a + b:
x = (a2 - b2 + np.square(d)) / (2 * d)
x2 = np.square(x)
y = np.sqrt(a2 - x2)
omega[stray] = (a2*np.arcsin(y/a) + b2*np.arcsin(y/b) -
y * (x + np.sqrt(b2 - a2 + x2)))
up = np.cross(vst, self.vm.dxyz)
r = d - x
ymtx, pmtx = translate.rmtx_yp(up)
vu = (pmtx@(ymtx@vst[:, None])).T
vu2 = translate.rotate_elem(vu, r, degrees=False)
v[s] = (ymtx.T@(pmtx.T@vu2.T)).T
mask[s] = True
self.view_mask = mask
self._vec = v[mask]
try:
self._lum = lum[mask]
except IndexError:
print(v, lum, omega)
self._lum = np.zeros(len(self._vec))
self.omega = omega[mask]
self.scale = scale
self.kwargs = kwargs
self._warn = warn
def __call__(self, metrics=None):
"""
Returns
-------
result: np.array
list of computed metrics
"""
if metrics is None:
metrics = self.defaultmetrics
else:
self.check_metrics(metrics, True)
if self._warn:
m = np.array([getattr(self, m) for m in metrics])
else:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
m = np.array([getattr(self, m) for m in metrics])
return m
[docs]
@classmethod
def check_metrics(cls, metrics, raise_error=False):
"""returns list of valid metric names from argument
if raise_error is True, raises an Atrribute Error"""
good = [m for m in metrics if m in cls.allmetrics]
if raise_error and len(good) != len(list(metrics)):
bad = [m for m in metrics if m not in cls.allmetrics]
raise AttributeError(f"'{bad}' are not defined in "
f"{cls.__name__}: {cls.allmetrics}")
return good
[docs]
@classmethod
def check_safe2sum(cls, metrics):
"""checks if list of metrics is safe to compute for seperate
sources before adding"""
mset = set(metrics)
return mset.issubset(cls.safe2sum)
@property
def vec(self):
return self._vec
@property
def lum(self):
return self._lum
@property
def omega(self):
return self._omega
@omega.setter
def omega(self, og):
"""correct omega of rays at edge of view to normalize view size"""
if self._correct_omega and len(self.vec) > 100:
self._omega = np.copy(og)
# square appoximation of ray area
ray_side = np.sqrt(self._omega)
excess = np.sum(og) - self.view_area
if abs(excess) > .1:
print(f"Warning, large discrepancy between sum(omega) and view "
f"area: {excess}", file=sys.stderr)
while True:
# get ray squares that overlap edge of view
onedge = self.radians > (self.vm.viewangle * np.pi / 360 - ray_side)
edgetotal = np.sum(og[onedge])
adjust = 1
if edgetotal > 0:
adjust -= excess/edgetotal
# if this fails increase search radius to ensure enough rays to
# absorb the adjustment
if adjust < 0:
print("Warning, intitial search radius failed for omega "
"adjustment", file=sys.stderr)
ray_side *= 1.1
else:
break
self._omega[onedge] = og[onedge] * adjust
else:
self._omega = og
self.view_area = np.sum(og)
# -------------------metric dependencies (return array)--------------------
@property
@functools.lru_cache(1)
def ctheta(self):
"""cos angle between ray and view"""
return self.vm.ctheta(self.vec)
@property
@functools.lru_cache(1)
def radians(self):
"""angle between ray and view"""
rad = np.arccos(self.ctheta)
return rad
@property
@functools.lru_cache(1)
def pos_idx(self):
return PositionIndex(self.guth).positions(self.vm, self.vec)
@property
@functools.lru_cache(1)
def pweight(self):
return self.omega/np.square(self.pos_idx)
# -----------------metric functions (return single value)-----------------
@property
@functools.lru_cache(1)
def pweighted_area(self):
return np.sum(self.pweight)
@property
@functools.lru_cache(1)
def illum(self):
"""illuminance"""
return np.einsum('i,i,i->', self.ctheta, self.lum,
self.omega) * self.scale
@property
@functools.lru_cache(1)
def avglum(self):
"""average luminance"""
return (np.einsum('i,i->', self.lum, self.omega) *
self.scale/self.view_area)
@property
@functools.lru_cache(1)
def maxlum(self):
"""average luminance"""
return np.max(self.lum) * self.scale
@property
@functools.lru_cache(1)
def pwavglum(self):
"""position weighted average luminance"""
return (np.einsum('i,i->', self.lum, self.pweight) *
self.scale/self.pweighted_area)
@property
@functools.lru_cache(1)
def avgraylum(self):
"""average luminance (not weighted by omega"""
return np.average(self.lum) * self.scale
@property
@functools.lru_cache(1)
def gcr(self):
"""a unitless measure of relative contrast defined as the average of
the squared luminances divided by the average luminance squared"""
a2lum = (np.einsum('i,i,i->', self.lum, self.lum, self.omega) *
self.scale**2/self.view_area)
if self.avglum > 0:
return a2lum/self.avglum**2
else:
return 1.0
@property
@functools.lru_cache(1)
def pwgcr(self):
"""a unitless measure of relative contrast defined as the average of
the squared luminances divided by the average luminance squared
weighted by a position index"""
a2lum = (np.einsum('i,i,i->', self.lum, self.lum, self.pweight) *
self.scale**2/self.pweighted_area)
if self.pwavglum > 0:
return a2lum/self.pwavglum**2
else:
return 1.0
@property
@functools.lru_cache(1)
def logpwgcr(self):
"""a unitless measure of relative contrast defined as the log of gcr"""
return np.log10(self.pwgcr)
@property
@functools.lru_cache(1)
def loggcr(self):
"""a unitless measure of relative contrast defined as the log of gcr"""
return np.log10(self.gcr)
@property
@functools.lru_cache(1)
def density(self):
"""average vector density of view representation"""
return self.omega.size / self.view_area