Source code for raytools.evaluate.hvsgsm

# -*- coding: utf-8 -*-
# Copyright (c) 2022 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 warnings

import numpy as np
from scipy.ndimage import convolve, gaussian_filter

import clasp.script_tools as cst
from raytools import io
from raytools import imagetools as itl
from raytools.evaluate import PositionIndex
from raytools.utility import pool_call
from raytools.mapper import ViewMapper


def gss_compute(imgs, illums=None, save=False, suffix="_rg.hdr", outdir=None,
                **kwargs):
    """initialize a GSS instance and compute multiple images in parallel

    Parameters
    ----------
    imgs: Sequence
        list of image file paths to compute. images should by 180 degree HDR
        angular fisheyes scaled at 1/179 cd/m^2 (standard radiance HDR)
    illums: Sequence, optional
        If images onnly contain glare sources but not an accurate background
        provide illuminance calculated seperately (like eDGPs process)
    save: bool, optional
        If true saves an image of the glare response
    suffix: str, optional
        suffix to append to image when save is True
    outdir: str, optional
        save response images to a different directory
    kwargs:
        passed to GSS initialization

    Returns
    -------
    GSS: list
        glare sensation scores for all images (in order given)
    """
    if outdir is not None and save:
        cst.try_mkdir(outdir)
    gss = GSS(imgs[0], **kwargs)
    if illums is not None:
        imgs = list(zip(imgs, illums))
    else:
        imgs = list(zip(imgs, [None]*len(imgs)))
    return pool_call(process_gss, imgs, gss, outdir=outdir, outf=save,
                     suffix=suffix, desc="calculating GSS", expandarg=True)


def process_gss(img, illum, ins, outf=False, outdir=None, suffix="_rg.hdr"):
    """called by gss_compute in parallel"""
    ins.lum = img
    if outf:
        saverg = img.rsplit(".", 1)[0] + suffix
        if outdir is not None:
            saverg = outdir + "/" + saverg.rsplit("/")[-1]
    else:
        saverg = None
    return ins.compute(save=saverg, ev_eye=illum)


def f_b(b, c, phi):
    """component of point spread function

    J.K. Ijspeert, T.J.T.P. Van Den Berg, H. Spekreijse, An improved
        mathematical description of the foveal visual point spread function
        with parameters for age, pupil size and pigmentation,
        Vision Research, Volume 33, Issue 1, 1993,Pages 15-20, ISSN 0042-6989,
        https://doi.org/10.1016/0042-6989(93)90053-Y.
    """
    return c*b/(2*np.pi*np.power(np.square(np.sin(phi)) +
                                 b**2*np.square(np.cos(phi)), 1.5))


def l_b(b, c, phi):
    """component of line spread function

        J.K. Ijspeert, T.J.T.P. Van Den Berg, H. Spekreijse, An improved
            mathematical description of the foveal visual point spread function
            with parameters for age, pupil size and pigmentation,
            Vision Research, Volume 33, Issue 1, 1993,Pages 15-20, ISSN 0042-6989,
            https://doi.org/10.1016/0042-6989(93)90053-Y.
        """
    return c*b/(np.pi*(np.square(np.sin(phi)) + b**2*np.square(np.cos(phi))))


[docs] class GSS: """calculate GSS for images with angular fisheye projection application of model described in: A GENERIC GLARE SENSATION MODEL BASED ON THE HUMAN VISUAL SYSTEM Vissenberg, M.C.J.M., Perz, M., Donners, M.A.H., Sekulovski, D. Signify Research, Eindhoven, THE NETHERLANDS gilles.vissenberg@signify.com DOI 10.25039/x48.2021.0P23 see methods for citations associated with each step in model. the model requires the following steps: Done when setting an image with a new resolution: 1. calculate solid angle of pixels 2. calculate eccentricity from guth position idx Steps for applying model to an image: 1. calculate eye illuminance from image 2. mask non-glare source pixels (REMOVED, only masks to 180 degree incidence) 3. calculate pupil area and diameter 4. calculate global retinal irradiance 5. calculate incident retinal irradiance of glare sources (REMOVED, calculate on totial irradiance) 6. apply PSF to (4) 7. apply movement affecting adaptation to (6) 8. apply movement affecting direct response to (6) 9. calculate local adaptation using (7) 10. calculate V/V_m photoreceptor response (8) 11. calculate receptor field response to (10) as DoG 12. normalize field response with logistic 13. sum GSS and apply position weighting Parameters ---------- view: can be None, a view file, a ViewMapper, or an hdrimage with a valid view specification (must be -vta) age: age of observer f: eye focal length scale: factor to apply to raw pixel values to convert to cd/m^2 pigmentation: from Ijspeert et al. 1993: mean for blue eyes: 0.16 brown eyes: 0.106 dark brown eyes: 0.056 fwidth: Union[int, float], optional the width of the frame for psf psf: bool, optional apply pointspread function for light arriving at retina adaptmove: bool, optional apply involuntary eye movement effect on local adaptation directmove: book, optional apply involuntary eye movement effect on direct cone response raw: bool, optional do not weight results, used for calibration Notes ----- set self.lum, either by initializing with an image, or with the parameter setter, then compute:: gss = GSS("img.hdr") gss.lum = "img.hdr" score = gss.compute() additional images can be loaded and computed with the parameter setter by calling images with the same resolution and view size on an initialized object, subsantial re-computation can be avoided. Alternatively, to get access to process arrays or to override pupil adaptation and or isolating glare sources:: e_g, pupa, pupd = self.adapt(ev_eye) r_g, parrays = self.glare_response(img_gs, e_g, pupa, pupd, return_arrays=True) For processing multiple images with the same GSS initialization in parallel, see hvsgsm.gss_compute() """ # eccentricity min and max scaling for field width in response # from Vissenberg et al. (.12, 0.009) emax = 0.12 emin = 0.009 # coefficients for logistic response function # from Vissenberg et al. (22, 0.25) fr_a = 22 fr_b = 0.25 # coeficient for DoG in linear response # from Vissenberg et al. 0.67 fr_k = 0.67 # minkowski normalization exponent # from Vissenberg et al. 4 norm = 4 # contrast for eye adaptation (higher value, less global adaptation: # 0.8 indoor (medium) # 0.9 outdoor (high) -- maybe daylight is always here? contrast = 0.8 def __init__(self, view=None, age=40, f=16.67, scale=179, pigmentation=0.106, fwidth=10, psf=True, adaptmove=True, directmove=True, raw=False): self.age = age self.f = f self.scale = scale self.pigmentation = pigmentation self.fwidth = fwidth self._blur = (psf, adaptmove, directmove) self._raw = raw # initialize properties set when an image is loaded self._res = 0 self._vecs = None self._omega = None self._mask = None self._nmask = None self._lum = None self._sigma_c = None self._pparcmin = None self._ecc = None if view is None: self.vm = ViewMapper(viewangle=180) elif isinstance(view, ViewMapper): self.vm = view else: try: self.vm = itl.hdr2vm(view) except ValueError: self.vm = ViewMapper(viewangle=180) except IOError: self.vm = itl.vf_to_vm(view) else: self.lum = view
[docs] def adapt(self, ev_eye=None): """step 1 in compute, adapt eye to image""" if self.lum is None: raise ValueError("cannot adapt until an image has been set") if ev_eye is None: ev_eye = np.einsum('i,i,i->', self.ctheta.flat[self.mask], self.lum.flat[self.mask], self.omega.flat[self.mask]) pupa, pupd = self.pupil(ev_eye) e_g = self.retinal_irradiance(ev_eye/np.pi, pupa) return e_g, pupa, pupd
[docs] def glare_response(self, img_gs, e_g, pupa, pupd, return_arrays=False): """step 3 in compute, apply steps of Vissenberg et al. model Parameters ---------- img_gs: np.array representing all glare sources e_g: float global retinal irradiance pupa: float pupil area (mm^2) pupd: float pupil diameter (mm) return_arrays: bool, optional if True returns second value with dict of process arrays else return r_w only Returns ------- r_w: np.array weighted glare response for entire retina as represented by image parrays: dict, optional with returned_arrays=True keys: retinal_irrad, psf, adapt_eye_movement, direct_eye_movement, local_adaptation, response_ratio, response_lin, response_log """ if self.lum is None: raise ValueError("cannot glare_response until an image has been set") parrays = dict() e_r = self.retinal_irradiance(img_gs, pupa) parrays["01_retinal_irrad"] = e_r if self._blur[0]: e_r_psf = self.apply_psf(e_r, pupd) parrays["02_psf"] = e_r_psf else: e_r_psf = e_r if self._blur[1]: e_rg = self.apply_eye_movement_1(e_r_psf) parrays["03_adapt_eye_movement"] = e_rg else: e_rg = e_r_psf if self._blur[2]: e_rc = self.apply_eye_movement_2(e_r_psf, e_g) parrays["04_direct_eye_movement"] = e_rc else: e_rc = e_r_psf e_a = self.local_eye_adaptation(e_rg, e_g) parrays["05_local_adaptation"] = e_a vvm = self.cone_response(e_rc, e_a) parrays["06_response_ratio"] = vvm # r_lin r_rf = self.field_response(vvm) parrays["07_response_lin"] = r_rf r_g = self.normalized_field_response(r_rf) bfill = np.zeros(r_g.shape[1:]) parrays["08_response_log"] = np.stack((*r_g, bfill)) if return_arrays: return r_g, parrays else: return r_g
[docs] def compute(self, save=None, ev_eye=None): """apply glare sensation model to loaded image Parameters ---------- save: str if given save response image to file specified (.hdr) ev_eye: float, opttional externally calculated Ev Returns ------- float """ if self.lum is None: raise ValueError("cannot compute until an image has been set") e_g, pupa, pupd = self.adapt(ev_eye) # swap comment to mask after response img_gs = self.lum r_g = self.glare_response(img_gs, e_g, pupa, pupd) if save is not None: r_gc = np.stack((*r_g, np.zeros(r_g.shape[1:]))) io.carray2hdr(r_gc, save) return self.gss(r_g)
@property def lum(self): return self._lum @property def ecc(self): return self._ecc @lum.setter def lum(self, img): """reads image and updates view based values if necessary""" self._lum = io.hdr2array(img)*self.scale r = self._lum.shape[0] if self._res != r: self._vecs = self.vm.pixelrays(r) self._omega = self.vm.pixel2omega(self.vm.pixels(r), r) self._mask = self.vm.in_view(self._vecs, False) self._nmask = np.logical_not(self._mask) self._omega.flat[self._nmask] = 0 ct = np.maximum(0, self.vm.ctheta(self._vecs)) self._ctheta = ct.reshape(self._lum.shape) self._res = r self._pparcmin = self.res/(60*self.vm.viewangle) self.sigma_c = r self._ecc = np.average(self.sigma_c, weights=np.power(self._lum, 4) * self.omega) @property def res(self): """resolution, set via lum""" return self._res @property def vecs(self): """directions, set via lum""" return self._vecs @property def omega(self): """solid angle, set via lum""" return self._omega @property def mask(self): """view mask, set via lum""" return self._mask @property def ctheta(self): """cos between vectors and view direction, set via lum""" return self._ctheta @property def sigma_c(self): """position index scaled to eccentricity .009-.12 (used in field_response) Note that this differs from the implementation dscribed by Vissenberg and incorporates KIM below the horizon """ return self._sigma_c @sigma_c.setter def sigma_c(self, r): # # model described by vissenberg with greater acceptance below horizon xy = self.vm.xyz2vxy(self._vecs.reshape(-1, 3))*2 - 1 vecc = np.where(xy[:, 1] >= 0, xy[:, 1]/0.61111, xy[:, 1]/0.94444) p = np.minimum(np.sqrt(np.square(xy[:, 0]/1.05556) + np.square(vecc)), 1) s_c = p*(self.emax - self.emin) + self.emin self._sigma_c = s_c.reshape(r, r) @property def vm(self): return self._vm @vm.setter def vm(self, view): if isinstance(view, ViewMapper): vm = view else: try: vm = itl.hdr2vm(view) except ValueError: vm = None if vm is None: vm = ViewMapper(viewangle=180) self._vm = vm # Step 3
[docs] def pupil(self, ev): """calculate pupil area Based on: Donners, Maurice & Vissenberg, Michel & Geerdinck, L.M. & Broek-Cools, J. (2015). A PSYCHOPHYSICAL MODEL OF DISCOMFORT GLARE IN BOTH OUTDOOR AND INDOOR APPLICATIONS. Parameters ---------- ev: illumiance at eye (lux) """ pd = (5 - 3*np.tanh(0.4*np.log10(ev/1600)) - 0.043*(self.age - 10)*np.exp(-ev/174)) return np.pi*(pd/2)**2, pd
# Step 4
[docs] def retinal_irradiance(self, lum, pupa): """adjust incident light on retina based on pupil size and focal-length from Vissenberg et al. 2021 equation (1): (1) E_r = A_p * L / f^2 E_r: local retinal irrradiance L: field luminance """ return (pupa / self.f**2) * lum
[docs] def prep_kernel(self): """construct an array to hold a kernel scaled to image resolution """ # approximates that relationship of pixels in cone is constant window = int(np.ceil(self.fwidth*60*self._pparcmin)) vm = ViewMapper(viewangle=self.fwidth) img, vecs, mask, _, _ = vm.init_img(window) phi = vm.radians(vecs[mask]) oga = vm.pixel2omega(self.vm.pixels(window), window) return img, phi, oga, mask
[docs] def psf_coef(self, pupd): """age, pupil size and pigmentation adjusted PSF coefficients PSF: PSF(phi) = sum(c * f_b(phi)) f_b(phi) = b/(2π * (sin^2(phi) + b^2*cos^2(phi))^1.5) 1/steradian LSF: LSF(phi) = sum(c * l_b(phi)) l_b(phi) = b/(π * (sin^2(phi) + b^2*cos^2(phi))) 1/rad based on: J.K. Ijspeert, T.J.T.P. Van Den Berg, H. Spekreijse, An improved mathematical description of the foveal visual point spread function with parameters for age, pupil size and pigmentation, Vision Research, Volume 33, Issue 1, 1993,Pages 15-20, ISSN 0042-6989, https://doi.org/10.1016/0042-6989(93)90053-Y. """ d = 3.2 agefactor = 1 + np.power(self.age/70, 4) b = 9000 - 936 * np.sqrt(agefactor) e = np.sqrt(agefactor) / 2000 pf = 1/self.pigmentation - 1 csa = 1 / (1 + agefactor/pf) cla = 1 / (1 + pf/agefactor) pd = (1 + (pupd/d)**2) dp = (1 + (d/pupd)**2) cs = [csa/pd, csa/dp, cla/((1 + 25*self.pigmentation)*(1 + 1/agefactor))] cs += [cla - cs[-1]] bs = [pd / (b * pupd), dp * (e - 1/(b * pupd)), 1 / (10 + 60 * self.pigmentation - 5 / agefactor), 1] return bs, cs
# Step 6
[docs] def apply_psf(self, e_r, pupd): """apply human foveal point spread function based on: J.K. Ijspeert, T.J.T.P. Van Den Berg, H. Spekreijse, An improved mathematical description of the foveal visual point spread function with parameters for age, pupil size and pigmentation, Vision Research, Volume 33, Issue 1, 1993,Pages 15-20, ISSN 0042-6989, https://doi.org/10.1016/0042-6989(93)90053-Y. """ bs, cs = self.psf_coef(pupd) # calculate how much of PSF is in our window using high resolution # spacing on LSF lphi = np.linspace(-self.fwidth/2, self.fwidth/2, int(self.fwidth/.002/2)*2 + 1)*np.pi/180 dlphi = lphi[1] - lphi[0] lsf = np.sum([l_b(b, c, lphi) for b, c in zip(bs, cs)], axis=0) cap_eg = np.sum(lsf*dlphi) # approximate remainder with constant (the flat part) e_gs = np.einsum('i,i->', e_r.flat[self.mask], self.omega.flat[self.mask]) base = e_gs * (1 - cap_eg) / np.sum(self.omega.flat[self.mask]) # build PSF filter psfk, phi, oga, mask = self.prep_kernel() psfk[mask] = np.sum([f_b(b, c, phi) for b, c in zip(bs, cs)], axis=0) # apply pixel solid angle psfk *= oga # hack to fix peak to normalize to 1 (needed because center PSF will # always be sharper than pixel resolution pk = np.unravel_index(psfk.argmax(), psfk.shape) psfk[pk] = 0 psfk[pk] = cap_eg - np.sum(psfk) # apply filter than balance energy outside window e_r_psf = convolve(e_r, psfk) e_r_psf += base return e_r_psf
# Step 7
[docs] def apply_eye_movement_1(self, e_r): """eye movement gaussian adaptation model to blur image at the time- scale of adaptation response. based on: R. A. Normann, B. S. Baxter, H. Ravindra and P. J. Anderton, "Photoreceptor contributions to contrast sensitivity: Applications in radiological diagnosis," in IEEE Transactions on Systems, Man, and Cybernetics, vol. SMC-13, no. 5, pp. 944-953, Sept.-Oct. 1983, doi: 10.1109/TSMC.1983.6313090. Parameters ---------- e_r: np.array retinal irradiance (optical correction) Returns ------- adapt_eye_movement: retinal irradiance (with adaptation scale movement and optical correction) """ # e ^ -1/2 (x/sigma)^2 a = 0.62 # B: 0.167 # sigma = 1 / (sqrt(2) * B) bs = 4.2341723424 * self._pparcmin c = 0.38 # D: .05 # sigma = 1 / (sqrt(2) * D) ds = 14.1421356237 * self._pparcmin # Norman describes a truncation at 30 arcmin trunc = 30 * self._pparcmin / ds e_rg = (a * gaussian_filter(e_r, bs) + c * gaussian_filter(e_r, ds, truncate=trunc)) return e_rg
# Step 8
[docs] def apply_eye_movement_2(self, e_r, e_g): """blur image due to eye movement during direct response from Vissenberg et al. 2021 equations (5) and (6): (5) τ = 100/(E_g * f^2)^0.12 ms tau (τ): cone integration time (6) w = 2 * sqrt(D * τ) D = 30.0 arcmin^2 * s^-1 (occular drift) D = 250.0 (micro saccades) Parameters ---------- e_r: np.array retinal irradiance (optical correction) e_g: float global retinal irrradiance Returns ------- direct_eye_movement: retinal irradiance (with movement and optical correction) """ t = .1 / np.power(e_g * self.f**2, 0.12) # D = 30.0 arcmin^2 * s^-1 (occular drift) w1 = 2 * np.sqrt(30 * t) * self._pparcmin # D = 250.0 (micro saccades) w2 = 2 * np.sqrt(250 * t) * self._pparcmin e_ra = gaussian_filter(e_r, w1) e_ra = gaussian_filter(e_ra, w2) return e_ra
# Step 9
[docs] def local_eye_adaptation(self, e_r, e_g): """calculate locallized eye adaptation from Vissenberg et al. 2021 equation (4): log_10(E_a) = p * log_10(E_r) + (1-p) * log_10(E_g) E_a: adaptation illuminance p: 0.8 (indoor / moderate) - 0.9 (outdoor / strong) contrast Parameters ---------- e_r: np.array retinal irradiance (optical correction) e_g: float global retinal irrradiance Returns ------- local_adaptation """ with warnings.catch_warnings(): warnings.simplefilter("ignore") log10er = np.where(e_r > 0, np.log10(e_r), 0) e_a = np.power(10, self.contrast * log10er + (1-self.contrast) * np.log10(e_g)) return e_a
# Step 10
[docs] @staticmethod def cone_response(e_r, e_a): """calculate local response as a fraction of maximum at current adaptation from Vissenberg et al. 2021 equations (2) and (3): (2) V/V_m = E_r^n / (E_r^n + σ^n) V: photoreceptor response V_m: maximum response E_r: local retinal illuminance (apply w to this E_r) n: 0.74 (3) σ = (5.701055^(1/2.55) + E_a^(1/2.55))^2.55 sigma (σ): half-saturation retinal illuminance value Parameters ---------- e_r: np.array retinal irradiance (with movement and optical correction) e_a: np.array local adaptation Returns ------- response_ratio """ n = 0.74 sigma = np.power(5.701055**(1/2.55) + np.power(e_a, 1/2.55), 2.55) ern = np.power(e_r, n) vvm = ern/(ern + sigma**n) return vvm
# Step 11
[docs] def field_response(self, vvm): """receptive field response from Vissenberg et al. 2021 equation (7):: R_RF(r) = e^(-r^2/(2σ_c^2)) / (2πσ_c^2) - K * e^(-r^2/(2σ_s^2)) / (2πσ_s^2) R_RF: receptive field response r: distance to receptive field center (degrees) σ_c: gaussian width of center (0.009 (center) - 0.12 (edge FOV) degrees) σ_s: gaussian width of surround 3.5 * σ_c K: DoG balance factor 0.67 Parameters ---------- vvm: np.array response_ratio (saturation) Returns ------- response_lin linear, difference of gussians """ rf_c = np.zeros(vvm.shape) rf_s = np.zeros(vvm.shape) # relative change in eccentricity is more important, so log is more # efficient steps = np.exp(np.linspace(np.log(self.emin), np.log(self.emax), 31)) ubounds = np.concatenate(((steps[1:] + steps[:-1])/2, [1])) lbounds = np.concatenate(([0], ubounds[:-1])) for s, lb, ub in zip(steps, lbounds, ubounds): include = np.logical_and(lb <= self.sigma_c, self.sigma_c < ub) sp = s * self._pparcmin * 60 r_c = gaussian_filter(vvm, sp) r_s = gaussian_filter(vvm, sp * 3.5) rf_c[include] = r_c[include] rf_s[include] = r_s[include] return rf_c - self.fr_k*rf_s
# Step 12
[docs] def normalized_field_response(self, r): """normalized non-linear ganglion response from Vissenberg et al. 2021 equation (8): R_G = 1 / (1 + e^(-a * (R_lin - b))) R_G: normalized non-linear ganglion response a: slope of logistic = 22 b: 0.25 Parameters ---------- r: np.array response_lin Returns ------- response_log logistic """ r_gc = 1/(1 + np.exp(-self.fr_a*(r - self.fr_b))) # track as a separate receptive field r_go = 1/(1 + np.exp(self.fr_a*(r + self.fr_b))) r_gc.flat[self._nmask] = 0 r_go.flat[self._nmask] = 0 return np.stack((r_gc, r_go))
# Step 14
[docs] def gss(self, r_g): """calculate minkowski sum on normalized response from Vissenberg et al. 2021 equation (9): (9) GSS = sum_i(R_G,i^m 𝛿_i)^(1/m) GSS: glare sensation score m: minkowski norm (4) delta (𝛿): solid angle of pixel (steradians) Notes ----- fit on guth data using BCD = 2843.58 * e^(x + 1.5 * x^2) / 179 with a 2.12 degree source and 34.26 cd/m^2 background:: numpy.polynomial.Polynomial.fit(ecc, fac, 7, window=[0, 1], domain=[.009, 0.12]) # where x = eccentricity (.009 -.12 from 0 to 55 degree vertical # angle and y = 1/unweighted GSS results:: 33.12797281707965 + 2.2872877726594725·x¹ - 104.61419835147568·x² - 275.45010218009116·x³ + 1587.8255352939432·x⁴ - 2570.6813747583033·x⁵ + 1837.1741161137818·x⁶ - 499.8491902780004·x⁷ """ gss = np.sum(np.power(r_g, self.norm)*self.omega)**(1/self.norm) if not self._raw: p = np.polynomial.Polynomial([33.12797281708, 2.28728777265947, -104.614198351476, -275.450102180091, 1587.82553529394, -2570.6813747583033, 1837.1741161137818, -499.849190278], window=[0, 1], domain=[0.009, 0.12]) gss *= p(self.ecc) return gss