"""
.. module :: Toolbox
:platform: Unix, Windows
:synopsis: Equipment model
This module packs models for equipment usually employed in PIV measurements.
.. moduleauthor :: Ricardo Entz <maiko at thebigheads.net>
.. license::
PyVSim v.1
Copyright 2013 Ricardo Entz
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from __future__ import division
import numpy as np
import matplotlib.pyplot as plt
import Utils
import Primitives
import MieUtils
from scipy.special import erf, gammainc
from scipy.interpolate import RectBivariateSpline, interp1d
import warnings
import gdal
import Core
MEMSIZE = 1e6
MEM_SAFETY = 8
STD_PARAM = 0.040
GLOBAL_TOL = 1e-8
GLOBAL_NDIM = 3
[docs]class Mirror(Primitives.Plane):
"""
This class initializes a 1x1m mirror, which can be resized by the
methods::
* :meth:`~Primitives.Plane.dimension` (preferred); or
* :meth:`~Primitives.Plane.length`
* :meth:`~Primitives.Plane.heigth`
"""
def __init__(self):
Primitives.Plane.__init__(self)
self.name = 'Mirror '+str(self._id)
self.surfaceProperty = self.MIRROR
[docs]class Dump(Primitives.Plane):
"""
This class initializes a 1x1m beam dump, that will stop any ray that
arrives at it.
* :meth:`~Primitives.Plane.dimension` (preferred); or
* :meth:`~Primitives.Plane.length`
* :meth:`~Primitives.Plane.heigth`
"""
def __init__(self):
Primitives.Plane.__init__(self)
self.name = 'Dump '+str(self._id)
self.surfaceProperty = self.DUMP
[docs]class Sensor(Primitives.Plane):
"""
This class describes a sensor. It is responsible for determining the size
of the camera field of view and also for recording particles.
The particle recording behavior is similar to the one described by Lecordier
and Westerweel in their `synthetic image generator
<http://link.springer.com/chapter/10.1007%2F978-3-642-18795-7_11>`_ .
Some features can be easily implemented such as:
* Quantum efficiency as a function of wavelength (as the recording function
receives the wavelength as a parameter)
* Light field measurement (the "virtualData" field can be used to store
more data)
But were not implemented until now.
"""
def __init__(self):
"""
--|---------------------------------->
| | z
| |
| |
| |
| |
| |
| |
| |
v ___________________________|
y
"""
Primitives.Plane.__init__(self)
self.name = 'Sensor '+str(self._id)
# self.heigth = 0.024 x y z
self.dimension = np.array([0, 0.0090, 0.0122])
# self.dimension = np.array([0, 0.024, 0.036])
# # ROW # COLUMN
# #0.0089 0.0118
self.resolution = np.array([1200, 1600])
self.pixelSize = np.array([7.40, 7.40])*1e-6
self.fillRatio = np.array([0.75, 0.75])
self.fullWellCapacity = 40e3
self.quantumEfficiency = 0.5
self.gain = 2.1 #photons / count
self.bitDepth = 14
self.backgroundMeanLevel = 244
self.backgroundNoiseStd = 50
self.rawData = None
self.saturationData = None
self.deadPixels = None
self.hotPixels = None
self.virtualData = None
self.color = [1,0,0]
self.clear()
[docs] def display(self,colormap='jet'):
"""
This function displays what is currently recorded in the camera sensor.
"""
plt.figure(facecolor = [1,1,1])
#imgplot = plt.imshow(self.readSensor()/(-1+2**self.bitDepth))
imgplot = plt.imshow(self.readSensor())
imgplot.set_cmap(colormap)
imgplot.set_interpolation('none')
plt.colorbar()
plt.show()
[docs] def createDeadPixels(self,probability):
"""
Creates a dead/hot pixel mapping for the sensor reading simulation
"""
deathMap = np.random.rand(self.resolution[0],self.resolution[1])
self.deadPixels = deathMap > probability*0.5
self.hotPixels = deathMap > 1 - probability*0.5
[docs] def clear(self):
"""
Initializes sensor with gaussian noise, the distribution parameters are
given by:
* backgroundMeanLevel - the mean value
* backgroundNoiseVar - the variance of the distribution
To handle negative values, only the absolute value is taken into account
"""
self.rawData = np.abs(self.backgroundMeanLevel +
self.backgroundNoiseStd *
np.random.randn(self.resolution[0],
self.resolution[1]))
[docs] def readSensor(self):
"""
Returns the sensor reading in counts, creating quantization noise and
saturating the signal where appropriate. This also simulates dead pixels,
if there is a dead pixel mapping
The readout noise, however, should be included in the background noise
property of the class
"""
s = self.rawData / self.fullWellCapacity
if self.deadPixels is not None:
s = s * self.deadPixels
s = s + self.hotPixels
self.saturationData = (s > 1)
# Corrects if any place is less than zero (may happen due to noise)
s = s - s*(s < 0)
# Corrects saturations
s = s - (s-1)*(s > 1)
# Return result as counts:
return np.round(s*(-1+2**self.bitDepth))
[docs] def save(self, filename):
"""
Writes the sensor data in a 16-bit TIFF file (which is compatible
with some mainstream PIV software)
Parameters
----------
filename : string
The file name and path (including the extension)
"""
data = self.readSensor().astype(np.uint16)
dr = gdal.GetDriverByName("GTiff")
outDs = dr.Create(filename,
int(self.resolution[1]),
int(self.resolution[0]),
1,
gdal.GDT_Int16)
outBand = outDs.GetRasterBand(1)
outBand.WriteArray(data)
outBand.FlushCache()
outBand.SetNoDataValue(-99)
# This seems to be the wonderful way of gdal to close files, sorry
dr = None
outDs = None
outBand = None
[docs] def parametricToSensor(self, param_coords):
"""
From the parametric coordinates :math:`\\\overline{U,V}`,
which range is :math:`[-1..1]`, calculates the sensor coordinates in
meters, so the algorithm is basically multiplying by the sensor size.
Parameters
----------
param_coords : numpy.ndarray (N,2)
The parametric coordinates in the range -1..1.
Returns
-------
sensor_coords : numpy.ndarray (N,2)
The sensor coordinates in meters
"""
dim_uv = self.dimension[1:][::-1]/2
uvlist = np.einsum("ij,j->ij",param_coords,dim_uv)
return uvlist
[docs] def sensorToParametric(self, sensor_coords):
"""
Transform sensor coordinates :math:`(U,V)` in meters to parametric
coordinates,
:math:`(\\\overline{U},\\\overline{V})`.
Parameters
----------
param_coords : numpy.ndarray (N,2)
The parametric coordinates in the range -1..1.
Returns
-------
sensor_coords : numpy.ndarray (N,2)
The sensor coordinates in meters
"""
dim_uv = 2/self.dimension[1:][::-1]
uvlist = np.einsum("ij,j->ij",sensor_coords,dim_uv)
return uvlist
[docs] def sensorToPhysical(self, sensor_coords):
"""
Transform sensor coordinates :math:`(U,V)` in meters to world coordinates
Parameters
----------
param_coords : numpy.ndarray (N,2)
The parametric coordinates in the range -1..1.
Returns
-------
sensor_coords : numpy.ndarray (N,3)
The sensor coordinates in meters
"""
return self.parametricToPhysical(self.sensorToParametric(sensor_coords))
[docs] def sensorToPixel(self, coords):
"""
Transforms (normalized) parametric coordinates into pixel position on the
sensor.
There is an inversion of the UV columns because of the unfortunate
parametric coordinate system that maps:
u -> sensor.z
v -> sensor.y
Parameters
----------
coords : numpy.ndarray (N,3)
The position in the sensor in normalized coordinates (range -1..1)
Returns
-------
pixels : numpy.ndarray (N,2)
The fractional position in sensor pixels in the format [row column]
DOES NOT CHECK IF OUTSIDE SENSOR BOUNDARIES!!!
"""
return self.parametricToPixel(self.sensorToParametric(coords))
[docs] def parametricToPixel(self,coordinates):
"""
Transforms (normalized) parametric coordinates into pixel position on the
sensor.
There is an inversion of the UV columns because of the unfortunate
parametric coordinate system that maps:
u -> sensor.z
v -> sensor.y
Parameters
----------
coords : numpy.ndarray (N,3)
The position in the sensor in normalized coordinates (range -1..1)
Returns
-------
pixels : numpy.ndarray (N,2)
The fractional position in sensor pixels in the format [row column]
DOES NOT CHECK IF OUTSIDE SENSOR BOUNDARIES!!!
"""
return 0.5*(coordinates[:,::-1]+1)*self.resolution
[docs] def physicalToPixel(self,coords):
"""
Transforms world coordinates into a position in the sensor, given in
pixels
Parameters
----------
coords : numpy.ndarray (N,3)
The position in world coordinates
Returns
-------
pixels : numpy.ndarray (N,2)
The fractional position in sensor pixels in the format [row column]
DOES NOT CHECK IF OUTSIDE SENSOR BOUNDARIES!!!
"""
return self.physicalToParametric(coords)*self.resolution
def _recordParticles(self,coords,energy,wavelength,diameter):
"""
coords - [u,v] sensor coordinates of the recorded point
energy - J - energy which is captured by the lenses
wavelength - m - illumination wavelength
diameter - m - particle image diameter
Writes the diffraction-limited image of a particle to the
sensor. This logic was described by Lecordier and
Westerweel on the SIG publication
The method is vectorized, but that demands the creation of
very large tensors (X, Y, gX0, gX1, gY0, gY1), which can
lead to a crash of the python intepreter. This is one of
the reasons that control of this function is given to the
public readSensor method.
Another issue is the masksize parameter. This determines the
dimensions of the tensor, which are::
[np of particles, masksize[0], masksize[1]]
This is because the tensors can be seen as a pile of "stickers"
each with a particle image on them. Then they are "glued" to
the sensor at the correct position.
The masksize must be big enough that the largest particle image
will still fit to the "sticker". However, an evaluation of the
error funcion (which is the integral of the gaussian profile)
is executed 4 times for each element of the tensors. So, if we
have one very large image and many small ones, we waste a lot
of computing pulseEnergy.
Do not forget the sign convention for the image
--|---------------------------------->
| | z
| |
| A____________ |
| | | |
| | | | A = anchor
| | C | | C = particle center
| | | |
| |____________| |
v ___________________________|
y
When particle image is partially out of the image limits, the
computation is done over a partially useless domain, but
remains correct.
--|---------------------------------->
| | z
| |
| |
| A____________|
| | | A = anchor
| | | C = particle center
| | |
| | |
v ______________|___________C|
y
The program performs an integration of a 2D gaussian distribution
over the sensitive areas of the pixel (defined by the fill ratio).
"""
# Classical formula E = h*c h = 6.62607e-34 J*s
# --------- c = 299 792 458 m/s
# lambda
photonEnergy = 6.62607e-34*299792458/wavelength
totalPhotons = energy / photonEnergy
sX = (diameter/self.pixelSize[0])*(0.44/1.22)
sY = (diameter/self.pixelSize[1])*(0.44/1.22)
#
# Filtering useless results
#
npts = np.size(coords,0)
killist = (range(1,npts+1)*
(coords[:,0] <= 1.01) * (coords[:,0] >= -1.01) *
(coords[:,1] <= 1.01) * (coords[:,1] >= -1.01) *
((totalPhotons / (sX * sY)) > 1))
killist = np.nonzero(killist)[0]
if np.size(killist) < 1:
return
if np.size(killist) != np.size(diameter):
diameter2 = diameter[killist]
coords2 = coords[killist]
totalPhotons = totalPhotons[killist]
sX = sX[killist]
sY = sY[killist]
else:
diameter2 = diameter
coords2 = coords
pixels = self.sensorToPixel(coords2)
#
# Auxiliary variables for vectorization
#
npts = np.size(coords2,0)
totalPhotons = np.reshape(totalPhotons,(npts,1,1))
pixelX = np.reshape(pixels[:,0],(npts,1,1))
pixelY = np.reshape(pixels[:,1],(npts,1,1))
sX = np.reshape(sX,(npts,1,1))
sY = np.reshape(sY,(npts,1,1))
frX = self.fillRatio[0]
frY = self.fillRatio[1]
# this masksize guarantees that the particle image will not be cropped
# when the particle size is too small
#
# here we take only the worst case
masksize = np.zeros(2)
masksize[0] = np.max(np.round(2.25*diameter2/self.pixelSize[0]))
masksize[1] = np.max(np.round(2.25*diameter2/self.pixelSize[1]))
masksize[masksize < 3] = 3
masksize[masksize > np.min(self.resolution)] = np.min(self.resolution)
# Defines the anchor position (cf. documentation above)
anchor = np.round(pixels - masksize/2)
# Important to verify if anchor will not force an incorrect matrix addition
# Case anchor has negative elements, stick them to the pixel zero
anchor[anchor < 0] = 0 * anchor[anchor < 0]
# Case anchor element is out of the matrix boundaries
anchor[:,0][anchor[:,0] + masksize[0] >
self.resolution[0]] = (self.resolution - masksize)[0]
anchor[:,1][anchor[:,1] + masksize[1] >
self.resolution[1]] = (self.resolution - masksize)[1]
anchorX = anchor[:,0]
anchorY = anchor[:,1]
anchorX = np.reshape(anchorX, (npts,1,1))
anchorY = np.reshape(anchorY, (npts,1,1))
[Y,X] = np.meshgrid(np.arange(masksize[0]),np.arange(masksize[1]))
X = X + anchorX - pixelX
Y = Y + anchorY - pixelY
# Magic Airy integral, in fact this is a 2D integral on the sensitive
# area of the pixel (defined by the fill ratio)
gX0 = erf((X - 0.5 * frX)*2/sX)
gX1 = erf((X + 0.5 * frX)*2/sX)
gY0 = erf((Y - 0.5 * frY)*2/sY)
gY1 = erf((Y + 0.5 * frY)*2/sY)
level = (0.5*((gX1-gX0)*(gY1-gY0))*
totalPhotons * self.quantumEfficiency / self.gain)
for (n, (ax,ay)) in enumerate(anchor):
self.rawData[ax:ax+masksize[0],ay:ay+masksize[1]] += level[n]
[docs] def recordParticles(self,
coords,
energy,
wavelength,
diameter,
ignoreLarge = 0.2):
"""
coords - [u,v] sensor coordinates of the recorded point
energy - J - energy which is captured by the lenses
wavelength - m - illumination wavelength
diameter - m - particle image diameter
This is the front-end of the _recordParticle method, its main input
is an array of sensor coordinates, representing the particle image
centers.
The other inputs can be either arrays (e.g. for particle with
varying diameters) or scalars (e.g. for all particles with same
diameter). It is more or less obvious that the arrays must have
the same length as the coordinate arrays.
Another issue is that the recording is much less efficient
when particle image sizes varying size (the source of this issue
is at the _recordParticles documentation). So some tricks (such
as sorting by particle size) are used to reduce this effect. This
procedure is made when the standard deviation exceeds 1/10th of the
particle diameter (ajustable by the STD_PARAM constant at the
beginning of the module).
Finally, as the recording itself is vectorized, but uses too much
memory, it is made in steps. If you find problems too often, adjust
the following constants at the header of this module::
MEMSIZE - maximum acceptable number of elements in a numpy.ndarray
MEM_SAFETY - factor of safety
"""
# filter out too large particles
if ignoreLarge > 0:
threshold = ignoreLarge*np.max(self.dimension)
if np.size(diameter) == 1 and diameter > threshold:
return
smallparticles = diameter < threshold
diameter = diameter[smallparticles]
coords = coords[smallparticles,:]
if np.size(wavelength) > 1:
wavelength = wavelength[smallparticles]
if np.size(energy) > 1:
energy = energy[smallparticles]
print "There are %i particles to be recorded" % np.size(coords,0)
meanDiameter = np.mean(diameter)
meanDiameter = meanDiameter / np.mean(self.pixelSize)
stepMax = np.round(MEMSIZE / (MEM_SAFETY * meanDiameter**2))
if stepMax == 0:
stepMax = 1
nparts = np.size(coords,0)
if stepMax < nparts and np.std(diameter) > STD_PARAM*np.mean(diameter):
print "Sorting"
indexes = np.argsort(diameter)
coords = coords[indexes]
else:
indexes = None
# Adjusting the inputs, so that _recordParticle will get uniform
# arrays always
if np.size(energy) > 1 and indexes is not None:
energy = energy[indexes]
else:
energy = energy * np.ones(nparts)
if np.size(wavelength) > 1 and indexes is not None:
wavelength = wavelength[indexes]
else:
wavelength = wavelength * np.ones(nparts)
if np.size(diameter) > 1 and indexes is not None:
diameter = diameter[indexes]
else:
diameter = diameter * np.ones(nparts)
if stepMax < nparts:
print "Recording partials in steps of %i" % stepMax
k = 0
nrec = 0
while (k+1)*stepMax < nparts:
self._recordParticles(coords [k*stepMax:(k+1)*stepMax],
energy [k*stepMax:(k+1)*stepMax],
wavelength [k*stepMax:(k+1)*stepMax],
diameter [k*stepMax:(k+1)*stepMax])
nrec += np.size(coords[k*stepMax:(k+1)*stepMax],0)
k = k + 1
self._recordParticles(coords [k*stepMax:],
energy [k*stepMax:],
wavelength [k*stepMax:],
diameter [k*stepMax:])
nrec += np.size(coords[k*stepMax:],0)
print "Recorded %i particles in %i steps" % (nrec, k+1)
else:
print "Recording total"
self._recordParticles(coords, energy, wavelength, diameter)
[docs]class Lens(Primitives.Part, Core.PyvsimDatabasable):
"""
This class represents an objective lens. The implemented model is a thick,
pupil-centric lens as described by `Aggarwal and Ahuja
<http://link.springer.com/article/10.1023%2FA%3A1016324132583>`_ .
This means that the center of projection is assumed to be at the center
of the entrance pupil.
This class generates the starting vectors for the ray tracing procedure. One
caveat is that it is not possible to execute ray tracing from the back of the
lens (i.e. from the sensor to the lens), as the rays are assumed straight
between these two points. This can be a problem when modelling stacked lenses,
that have to be substituted by an equivalent one.
"""
def __init__(self):
Primitives.Part.__init__(self)
Core.PyvsimDatabasable.__init__(self)
self.name = 'Lens '+str(self._id)
self.dbName = "Lenses"
self.dbParameters = ["color", "opacity", "diameter", "length",
"flangeFocalDistance", "F", "H_fore_scalar",
"H_aft_scalar", "E_scalar", "X_scalar",
"_Edim", "_Xdim", "distortionParameters"]
# Plotting parameters
self.color = [0.2,0.2,0.2]
self.opacity = 0.8
self.diameter = 0.076
self.length = 0.091
self._createPoints()
# Main planes model
self.flangeFocalDistance = 0.0440
self.F = 0.1000
self._H_fore_scalar = 0.0460
self._H_aft_scalar = 0.0560
self._E_scalar = 0.0214
self._X_scalar = 0.0362568218298555
self._Edim = 0.1000
self._Xdim = 0.0802568218
# Adjustable parameters
self._focusingDistance = 10
self.macro = False
self.aperture = 2
self.distortionParameters = np.array([0,0,0,0,
0,0,0,0,
0,0,0,0])
#Calculated parameters
self.focusingOffset = 0
self.PinholeFore = None
self.PinholeAft = None
self._calculatePositions()
@property
def H_fore(self): return self.x * self.H_fore_scalar + self.origin
@H_fore.setter
def H_fore(self, h): self._H_fore_scalar = h
@property
def H_aft(self): return self.x * self.H_aft_scalar + self.origin
@H_aft.setter
def H_aft(self, h): self.H_aft_scalar = h
@property
def H_fore_scalar(self): return (self._H_fore_scalar + self.focusingOffset)
@property
def H_aft_scalar(self): return (self._H_aft_scalar + self.focusingOffset)
@property
def E_scalar(self): return (self._E_scalar + self.focusingOffset)
@property
def X_scalar(self): return (self._X_scalar + self.focusingOffset)
@property
def E(self): return self.x*self.E_scalar + self.origin
@E.setter
def E(self, e): self.E_scalar = e
@property
def X(self): return self.x*self.X_scalar + self.origin
@X.setter
def X(self, x): self.X_scalar = x
@property
def focusingDistance(self): return self._focusingDistance
@focusingDistance.setter
def focusingDistance(self,distance):
self._focusingDistance = distance
self._calculatePositions()
@property
def Edim(self): return self._Edim / self.aperture
@Edim.setter
def Edim(self, entrancePupilDiameter): self._Edim = entrancePupilDiameter
@property
def Xdim(self): return self._Xdim / self.aperture
@Xdim.setter
def Xdim(self, pupilDiameter): self._Xdim = pupilDiameter
[docs] def display(self):
"""
This method creates a plot showing the position of the lens notable
planes (the two main planes, the pupils and the focusing offset). This
is intended for debugging purposes, or better understanding how the
lens work.
"""
plt.figure(facecolor = [1,1,1])
plt.hold(True)
plt.axis("equal")
plt.grid(True, which = "both", axis = "both")
plt.title("Notable planes position, reference - lens flange")
plt.plot([-self.diameter/2,
self.diameter/2,
self.diameter/2,
-self.diameter/2,
-self.diameter/2],
[0,0,self.length,self.length,0],
"k",
#label="External contour",
linewidth=4)
plt.plot([0,0],
[-0.1*self.length,1.1*self.length],
"k-.",
#label="Centerline",
linewidth=1)
plt.plot([-self.diameter/1.5,
self.diameter/1.5],
[self.H_aft_scalar,self.H_aft_scalar],
"b",
label="H'",
linewidth=2)
plt.plot([-self.diameter/1.5,
self.diameter/1.5],
[self.H_fore_scalar,self.H_fore_scalar],
"r",
label="H",
linewidth=2)
plt.plot([-self.diameter/1.5,
self.diameter/1.5],
[self.focusingOffset,self.focusingOffset],
"k",
label="d'",
linewidth=1)
plt.plot([-self.diameter/1.5,-self.Edim/2],
[self.E_scalar,self.E_scalar],
"r",
label="E",
linewidth=3)
plt.plot([self.diameter/1.5,self.Edim/2],
[self.E_scalar,self.E_scalar],
"r",
# label="E",
linewidth=3)
plt.plot([-self.diameter/1.5,-self.Xdim/2],
[self.X_scalar,self.X_scalar],
"b",
label="X",
linewidth=3)
plt.plot([self.diameter/1.5,self.Xdim/2],
[self.X_scalar,self.X_scalar],
"b",
# label="X",
linewidth=3)
plt.legend()
plt.show()
def _calculatePositions(self):
"""
Calculate some important points, must be called whenever the lens
is moved.
The position of the exit pinhole is calculated as proposed by:
Aggarwal, M. & Ahuja, N. A
Pupil-centric model of image formation
Internation Journal of Computer Vision, 2002, 48, 195-214
"""
# First, we have to calculate d', which is the distance the lens
# has to offset to focus at the given "focusingDistance"
# 1 1 1
# ----- = ----------- + ----------------------------
# F F + d' focusingDistance - SH - d'
#
# -(foc - F - H) +- sqrt((foc-f-H)**2 + 4*F**2))
# dprime = ----------------------------------------------
# 2
aux = self.focusingDistance - self.F - (self._H_fore_scalar +
self.flangeFocalDistance)
delta = aux**2 - 4*(self.F**2)
d_line1 = (aux - np.sqrt(delta))/2
d_line2 = (aux + np.sqrt(delta))/2
if self.macro:
d_line = d_line2
else:
d_line = d_line1
# print "------ FOCUS CALCULATION ------------------------"
# print "foc : ", self.focusingDistance
# print "aux : ", aux
# print "F : ", self.F
# print "H : ", (self._H_fore_scalar +
# self.flangeFocalDistance)
# print "sqrt(delta) : ", np.sqrt(delta)
# print "d_line : ", d_line
# print "X : ", self.X
# print "Hprime : ", self.H_aft
# print "H : ", self.H_fore
# print "E : ", self.E
#
# Now, we have to find the pseudo position of the pinhole (which is
# not H_fore.
v = d_line + self.F
a = self._E_scalar - self._H_fore_scalar
v_bar = v + a - v*a/self.F
# print "foc %.5f \nv %.5f \na %.5f \nv_bar %.5f" % (self.focusingDistance,
# v, a, v_bar)
self.focusingOffset = d_line
self.PinholeAft = self.origin + self.x * (v_bar -
self.flangeFocalDistance)
self.PinholeFore = self.E
[docs] def clearData(self):
"""
As the data from the Primitives.Part class that has to be cleaned is
used only for ray tracing, the parent method is not called.
The notable points, however, are recalculated.
"""
self._calculatePositions()
def _createPoints(self):
"""
This is needed to plot the lens. This will create and the
points and the connectivity list of a tube.
"""
NPTS = 20
pts = []
conn = []
for l in range(2):
for n in range(NPTS):
pts.append(l*self.length*self.x + self.diameter * 0.5 *
((np.cos(2*np.pi*n/NPTS)*self.y) +
(np.sin(2*np.pi*n/NPTS)*self.z)))
for n in range(NPTS-1):
conn.append([n,n+1,n+NPTS])
conn.append([n+NPTS+1,n+NPTS,n+1])
conn.append([NPTS-1, 0, NPTS])
conn.append([NPTS, 2*NPTS-1, NPTS-1])
self.points = np.array(pts) + self.origin
self.connectivity = np.array(conn)
[docs] def rayVector(self,p):
"""
Given a set of points (e.g. in the sensor), will return a list of
vectors representing the direction to be followed by ray tracing.
"""
return self.lensDistortion(Utils.normalize(self.PinholeAft - p))
[docs] def lensDistortion(self, vectors):
"""
TODO - Implementation of radial distortion model
"""
# return vectors
angler = np.arccos(np.einsum("ij,j->i",vectors,self.x))
angler = np.reshape(angler,(-1,1))
angley = np.arccos(np.einsum("ij,j->i",vectors,self.y))-np.pi/2
angley = np.reshape(angley,(-1,1))
anglez = np.arccos(np.einsum("ij,j->i",vectors,self.z))-np.pi/2
anglez = np.reshape(anglez,(-1,1))
raxis = Utils.normalize(np.cross(self.x,vectors))
yaxis = np.einsum("j,ij->ij",self.z,np.ones_like(angley))
zaxis = np.einsum("j,ij->ij",self.y,np.ones_like(anglez))
# print "BLAH blah"
# print self.distortionParameters[0:4]
# print
# print np.hstack([angler,angler**2,angler**3,angler**4])
# print np.hstack([angley,angley**2,angley**3,angley**4])
d_angler = np.einsum("j,ij->i",
self.distortionParameters[0:4],
np.hstack([angler,angler**2,angler**3,angler**4]))
d_angley = np.einsum("j,ij->i",
self.distortionParameters[4:8],
np.hstack([angley,angley**2,angley**3,angley**4]))
d_anglez = np.einsum("j,ij->i",
self.distortionParameters[8:12],
np.hstack([anglez,anglez**2,anglez**3,anglez**4]))
vectors = Utils.rotateVector(vectors, d_angler, raxis)
vectors = Utils.rotateVector(vectors, d_angley, yaxis)
vectors = Utils.rotateVector(vectors, d_anglez, zaxis)
return vectors
# npts = np.size(vectors,0)
# # Gets the angle between v and the optical axis
# Ti = np.arccos(np.sum(self.x * \
# np.reshape(vectors,(npts,1,GLOBAL_NDIM)),2)).squeeze()
#
# To = np.sum(self.distortionParameters * \
# np.array([Ti**4,Ti**3,Ti**2,Ti]),2)
#
# axis = Utils.normalize(np.cross(self.x,vectors))
# return Utils.rotateVector(vectors,(To-Ti),axis)
[docs]class Camera(Primitives.Assembly):
"""
This class represents a camera composed of a body (used only for display),
a sensor and a lens, therefore it is an assembly.
The main functions of this class are driving the sensor and the lens
together, so that the user can call more logical functions (such as
initialize) instead of using a complicated series of internal functions.
The camera creates a mapping of world coordinates into sensor coordinates
by using direct linear transformations (a pinhole model). Lens imperfections
and ambient influence can be modeled by using several DLTs (which is
controlled by the parameter mappingResolution).
"""
def __init__(self):
Primitives.Assembly.__init__(self)
self.name = 'Camera '+str(self._id)
self.lens = None
self.sensor = None
self.body = None
# Plotting properties
self.color = [0.172,0.639,0.937]
self.opacity = 0.650
self.dimension = np.array([0.175,0.066,0.084])
# Geometrical properties
self.sensorPosition = -0.017526
self._scheimpflugAngle = 0
# Mapping properties
self.mappingResolution = [2, 2]
self.circleOfConfusionDiameter = 29e-6
self.referenceWavelength = 532e-9
self.mapping = None
self.detmapping = None
self.dmapping = None
self.virtualApertureArea = None
self.sensorSamplingCenters = None
self.physicalSamplingCenters = None
# Create and position subcomponents:
self._positionComponents()
def clearData(self):
self.mapping = None
self.detmapping = None
self.dmapping = None
self.virtualApertureArea = None
self.rawPupilSolidAngle = None
self.sensorSamplingCenters = None
self.physicalSamplingCenters = None
while len(self._items) > 3:
self.remove(3)
@property
[docs] def bounds(self):
"""
This signals the ray tracing implementation that no attempt should be
made to intersect rays with the camera
"""
return None
@property
def scheimpflugAngle(self): return self._scheimpflugAngle
@scheimpflugAngle.setter
def scheimpflugAngle(self, value):
"""
This unfortunate vicious behavior is implemented to make sure that the
_scheimpflugAngle property is always up-to-date, and if one tries to set
that a meaningful error message is given.
If python supported real overloading, the problem would be solved easily
"""
raise NotImplementedError("Please use the setScheimpflugAngle function")
[docs] def setScheimpflugAngle(self, angle, axis):
"""
This is a convenience function to set the Scheimpflug angle as in a
well-build adapter (which means that the pivoting is performed through
the sensor center).
Parameters
----------
angle : float (radians)
The scheimpflug angle
axis : numpy.array (3)
The axis of rotation
"""
self.lens.alignTo(self.x,
self.y,
None,
self.origin + self.x*self.sensorPosition)
self.rotate(-angle, axis, self.origin + self.x*self.sensorPosition)
self.lens.rotate(angle, axis, self.origin + self.x*self.sensorPosition)
def _positionComponents(self):
"""
This method is a shortcut to define the initial position of the camera,
there is a definition of the initial positioning of the sensor and the
lens.
"""
if self.lens is not None:
self.remove(self.lens)
self.remove(self.body)
self.remove(self.sensor)
self.lens = Lens()
self.sensor = Sensor()
self.body = Primitives.Volume(self.dimension)
self.body.color = self.color
self.body.opacity = self.opacity
self.body.translate(-self.x*self.dimension[0])
self.append(self.lens)
self.append(self.sensor)
self.append(self.body)
# Adaptation in case lens is not compatible with camera (different
# flange focal distances)
self.lens.translate(self.x*
(self.lens.flangeFocalDistance +
self.sensorPosition))
# Sensor position adjustment
self.sensor.translate(self.x*self.sensorPosition)
[docs] def mapPoints(self, pts, skipPupilAngle = False):
"""
This method determines the position that a set of points x,y,z map on
the camera sensor.
In order to optimize calculation speed, a lot of memory is used, but
the method seems to run smoothly up to 2M points in a 5x5 mapping (2GB
of available RAM)
The number of elements in the bottleneck matrix is:
N = npts * 3 * mappingResolution[0] * mappingResolution[1]
Parameters
----------
pts : numpy.array (N,3)
A collection of points in the space
skipPupilAngle : boolean
Setting this flag to true skip the step of calculating the solid
angle formed by the given points and the pupils. This is used only
in the initialization of the camera
Returns
-------
uv : numpy.array (N,3)
The points (in sensor homogeneous coordinates) mapped to the
sensor
w : numpy.array (N)
The distance from the center of projection, as calculated by the
DLT matrix
dudv : numpy.array (N,6)
The derivatives of the coordinates u,v with respect to x,y,z in the
following order:
[du/dx, du/dy, du/dz, dv/dx, dv/dy, dv/dz]
lineOfSight : numpy.array (N,3)
The line of sight vectors (the direction of the light ray that
goes from the point to the camera center of projection)
imdim : numpy.array(N)
The diameter of the image as generated by a point source (consider
geometrical size + diffraction-limited size)
pupilSolidAngle : numpy.array(N)
The solid angle formed by the entrance pupil and the given points.
If flag skipPupilAngle is true, returns None
"""
npts = np.size(pts,0)
# Calculate vectors from samplingCenters to given points
d = self.physicalSamplingCenters - np.reshape(pts,(npts,1,1,3))
#print d.shape, d.nbytes
# Calculate squared norms of vectors
d = np.einsum('ijkl,ijkl->ijk',d,d)
# Flatten arrays to find minima
# print self.physicalSamplingCenters
# print d
d = np.reshape(d,(npts,-1))
# print d
indexes = np.argmin(d,1)
# Calculate indexes
# print indexes
j = np.mod(indexes,(self.mappingResolution[1]-1))
i = ((indexes - j) / (self.mappingResolution[0]-1))
i = i.astype('int')
# Calculate DLT
uvw = np.einsum('ijk,ik->ij',self.mapping[i,j],
np.hstack([pts,np.ones((npts,1))]))
w = uvw[:,2]
uv = np.einsum("ij,i->ij", uvw[:,:2], 1/uvw[:,2])
# print i,j
# print self.dmapping[i,j].shape
# print result.shape
duvw = np.einsum('ijk,ik->ij',self.dmapping[i,j],uvw)
duvw = np.einsum("ij,i->ij", duvw, 1/w**2)
dudx = duvw[:,:3]
dvdx = duvw[:,3:]
# print dudx
lineofsight = np.cross(dudx,dvdx)
# cheap norm # invert if mirror
lineofsightnorm = (np.sqrt(np.sum(lineofsight*lineofsight,1))*
np.sign(self.detmapping[i,j]))
lineofsight = -lineofsight / np.tile(lineofsightnorm,(3,1)).T
"""Calculate the diameter of the geometric image"""
pts_sensor = self.sensor.sensorToPhysical(uv)
HpS = np.sum((self.lens.H_aft - pts_sensor) * self.lens.x,1)
HpX = self.lens.X_scalar - self.lens.H_aft_scalar
pprime = w + self.lens.E_scalar - self.lens.H_fore_scalar
p = (self.lens.F * pprime) / (pprime - self.lens.F)
imdim = 0*self.lens.Xdim * (p - HpS) / (p - HpX)
# Diffraction-limited part
imdim += 2.44*self.referenceWavelength*self.lens.aperture
# Calculates the solid angle "seen" by the points
if skipPupilAngle:
pupilSolidAngle = None
else:
pupilSolidAngle = self.virtualApertureArea / w**2
# print "W", np.min(w), np.median(w), np.mean(w), np.max(w)
# print "imdim", np.min(imdim), np.median(imdim), np.mean(imdim), np.max(imdim)
# print "F", self.lens.F
# print "p'", np.min(pprime), np.median(pprime), np.mean(pprime), np.max(pprime)
# print "p", np.min(p), np.median(p), np.mean(p), np.max(p)
# print "H'S", HpS
# print "H'X", HpX
return (uv, w, duvw, lineofsight, imdim, pupilSolidAngle)
def _shootRays(self,
sensorParamCoords,
maximumRayTrace = 10,
restart = False):
"""
This is a convenience method to create a ray bundle departing from the
fore pinhole (the center of the entrance pupil) to be used in ray
tracing.
Parameters
----------
sensorParamCoords : numpy.array (N,2)
The UV coordinates of the point in the sensor originating the rays
maximumRayTrace : float
The maximum distance for the ray to be traced
restart : boolean (False)
If the previous tracing needs to be continued, setting this flag
to True will make the process continue from its last point
(don't forget to increase maximumRayTrace, which refers to the total
distance travelled by the ray, including from previous runs)
"""
if not restart:
sensorCoords = self.sensor.parametricToPhysical(sensorParamCoords)
# Creates vectors to initialize ray tracing for each point in the
# sensor
initialVectors = self.lens.rayVector(sensorCoords)
bundle = Primitives.RayBundle()
bundle.name = self.name + "RayTracingBundle"
bundle.append(initialVectors,
self.lens.PinholeFore,
self.referenceWavelength)
try:
self.remove(bundle.name)
except IndexError:
pass
self.append(bundle)
bundle.maximumRayTrace = maximumRayTrace
bundle.stepRayTrace = np.mean(maximumRayTrace) / 2
bundle.trace(tracingRule = Primitives.RayBundle.TRACING_FOV,
restart = restart)
return bundle
def _calculateMappings(self, target):
"""
This method calculates the transformation matrix(ces) to go from
world coordinates (XYZ) to parametric sensor coordinates (UV). This is
a method to avoid having to do ray tracing for each particle, when
generating a synthetic PIV image, for example.
The field of view is mapped using a volume (target), and it is assumed
that the light path is rectilinear inside it. The field of view is
discretized in MxN regions, as defined in the mappingResolution
property of the camera.
Discretization in more than one volume is only needed in cases where
the pinhole camera model is not valid, e.g. in the presence of radial
distortions or refractive elements. In theory any mapping is represented
in a piecewise linear manner using the domain partition, however this
makes computation of synthetic images much more expensive.
"""
# First determine the points in the sensor to be reference for the
# mapping
[U,V] = np.meshgrid(np.linspace(-1,1,self.mappingResolution[1]),
np.linspace(-1,1,self.mappingResolution[0]))
# print V
# print U
parametricCoords = np.vstack([U.ravel(), V.ravel()]).T
UV = np.reshape(parametricCoords,
(np.size(U,0),np.size(U,1),2))
# print UV
bundle = self._shootRays(parametricCoords,
maximumRayTrace = self.lens.focusingDistance*2)
# self += bundle
# self += target
# import System
# System.plot(self)
# Finds the intersections that are important:
intersections = (target == bundle.rayIntersections)
# Finds first and last points
intersections = np.tile(intersections,GLOBAL_NDIM)
firstInts = np.zeros_like(bundle.rayPaths[0])
lastInts = np.zeros_like(bundle.rayPaths[0])
mask = np.ones_like(bundle.rayPaths[0])
for n in range(np.size(bundle.rayPaths,0)):
firstInts[mask * intersections[n] == 1] = \
bundle.rayPaths[n][mask * intersections[n] == 1]
mask = (intersections[n] == 0) * (mask == 1)
lastInts[intersections[n] == 1] = \
bundle.rayPaths[n,intersections[n] == 1]
bundle = None
firstInts = np.reshape(firstInts,
(np.size(U,0),np.size(U,1),GLOBAL_NDIM))
lastInts = np.reshape(lastInts,
(np.size(U,0),np.size(U,1),GLOBAL_NDIM))
# print UV
# print firstInts
# print lastInts
XYZ = (firstInts + lastInts) / 2
self.sensorSamplingCenters = (UV[:-1,:-1] + UV[:-1,1:] +
UV[1:,1:] + UV[1:,:-1])/4
self.physicalSamplingCenters = (XYZ[:-1,:-1] + XYZ[:-1,1:] +
XYZ[1:,1:] + XYZ[1:,:-1])/4
self.mapping = np.empty((np.size(UV,0)-1,
np.size(UV,1)-1,
3, 4)) #each mapping matrix is 3x4
self.detmapping = np.empty((np.size(UV,0)-1,
np.size(UV,1)-1)) #determinants are scalar
self.dmapping = np.empty((np.size(UV,0)-1,
np.size(UV,1)-1,
6, 3)) #each derivative matrix is 6x3
cond = 0
for i in range(np.size(self.sensorSamplingCenters,0)):
for j in range(np.size(self.sensorSamplingCenters,1)):
uvlist = np.array([UV[i ,j ],
UV[i+1,j ],
UV[i+1,j+1],
UV[i ,j+1],
UV[i ,j ],
UV[i+1,j ],
UV[i+1,j+1],
UV[i ,j+1]])
# We want the DLT to be from meters to meters:
uvlist = self.sensor.parametricToSensor(uvlist)
xyzlist = np.array([firstInts[i ,j ],
firstInts[i+1,j ],
firstInts[i+1,j+1],
firstInts[i ,j+1],
lastInts[i ,j ],
lastInts[i+1,j ],
lastInts[i+1,j+1],
lastInts[i ,j+1]])
try:
(self.mapping[i,j,:,:],
self.dmapping[i,j,:,:],
self.detmapping[i,j],
temp1,
_) = Utils.DLT(uvlist,xyzlist)
except np.linalg.linalg.LinAlgError:
self.mapping = None
self.detmapping = None
self.dmapping = None
warnings.warn("Could not find a valid mapping", Warning)
return
cond = cond + temp1
cond = cond / (np.size(self.sensorSamplingCenters)/3)
return cond
[docs] def initialize(self):
"""
This function calculates the camera field of view and its mapping
functions to relate world coordinates to sensor coordinates. This
should be called whenever the ambient is set up, so the camera can
be used for synthetic image generation and displaying.
"""
vv,vh = self._depthOfField()
# Make sure rays intersect volume by expanding it a little
vv.expand(0.005)
self.parent += vv
self._calculateMappings(vv)
self.parent.remove(vv)
# Determines the distance mapped by the DLT for the points, so that
# the solid angle can be calculated individually for each particle.
# Only vh is used, assuming that astigmatism is not high
w = self.mapPoints(vh.points, skipPupilAngle = True)[1]
self.virtualApertureArea = np.mean(vh.data * w**2)
def _depthOfField(self):
"""
This method calculates the camera field of view and depth of field.
Two volumes are returned - one for vertical focusing and another for
horizontal focusing (when the ambient has no refractive elements, both
will probably be the same).
Returns
-------
(VV, VH) : pyvsim.Volume
Each of the volumes represent the region where a point is imaged
by the camera as a feature with a diameter no greater than
"allowableDiameter". Only in the case of astigmatism, VV and VH
are not the same, then VV (vertical) is the volume where the point
is in focus at the camera.y axis and VH (horizontal) is in focus
at the camera.z axis
One important point is that the field .data of the volumes has the
solid angle formed by the entrance pupil image as "seen" by the
particle. This is used in scattering calculations
"""
points_param = np.array([[-1,-1],
[-1,+1],
[+1,+1],
[+1,-1]])
points = self.sensor.parametricToPhysical(points_param)
X = self.lens.Xdim
HprimeX = self.lens.H_aft_scalar - self.lens.X_scalar
dcoc = self.circleOfConfusionDiameter
# The distance between sensor extremities and center of fore main
# plane, projected at the lens optical axis
points_proj = np.sum((self.lens.H_aft - points)*self.lens.x,1)
p_prime_fore = (X*points_proj + dcoc*HprimeX) / (X + dcoc)
p_prime_aft = (X*points_proj - dcoc*HprimeX) / (X - dcoc)
# p_prime_spot = points_proj
p_fore = self.lens.F*p_prime_fore / (p_prime_fore - self.lens.F)
p_aft = self.lens.F*p_prime_aft / (p_prime_aft - self.lens.F)
# p_spot = self.lens.F*p_prime_spot / (p_prime_spot - self.lens.F)
""" Find the vectors emerging from the lens: """
# print "=--------------------- DOF CALC --------------------------"
# print "pts\n", points
# print "H ", self.lens.H_fore
# print "H'", self.lens.H_aft
# print "E ", self.lens.E
# print "X ", self.lens.X
# print "d'", self.lens.focusingOffset
# print "fl", self.lens.origin
# print "ph_aft ", self.lens.PinholeAft
# print "ph_fore", self.lens.PinholeFore
# print "p_fore\n", p_fore
# print "p_aft\n", p_aft
# print "p_spot\n", p_spot
vecs = self.lens.rayVector(points)
vecx = np.sum(vecs*self.lens.x, 1) # projection at optical axis
p_fore += self.lens.H_fore_scalar - self.lens.E_scalar
p_aft += self.lens.H_fore_scalar - self.lens.E_scalar
# p_spot += self.lens.H_fore_scalar - self.lens.E_scalar
# "Elongate" points to adapt to ray tracing
p_fore = np.einsum("i,ij->ij", p_fore * 1/vecx,vecs) + self.lens.E
p_aft = np.einsum("i,ij->ij", p_aft * 1/vecx,vecs) + self.lens.E
# p_spot = np.einsum("i,ij->ij", p_spot * 1/vecx,vecs) + self.lens.E
""" p_fore and p_aft are the points in space limiting the in-focus
region, were there no obstructions, reflection, etc """
p_fore_horz = np.empty_like(p_fore)
p_fore_vert = np.empty_like(p_fore)
p_aft_horz = np.empty_like(p_aft)
p_aft_vert = np.empty_like(p_aft)
vv_angles = np.empty(8)
vh_angles = np.empty(8)
for n in range(np.size(p_fore,0)):
(pts, ang) = self._findFocusingPoint(p_fore[n])
p_fore_vert[n] = pts[0]
p_fore_horz[n] = pts[1]
vv_angles[n] = ang[0]
vh_angles[n] = ang[1]
(pts, ang) = self._findFocusingPoint(p_aft[n])
p_aft_vert[n] = pts[0]
p_aft_horz[n] = pts[1]
vv_angles[4+n] = ang[0]
vh_angles[4+n] = ang[1]
# Remove duplicates (in case calculation has already been done)
try:
self.remove("In-focus-vertical")
except IndexError:
pass
try:
self.remove("In-focus-horizontal")
except IndexError:
pass
volume_vert = Primitives.Volume()
volume_vert.surfaceProperty = volume_vert.TRANSPARENT
volume_vert.name = "In-focus-vertical"
volume_vert.color = np.array([1,0,0])
volume_vert.opacity = 0.25
volume_vert.points = np.vstack([p_aft_vert,p_fore_vert])
volume_vert.data = vv_angles
self.append(volume_vert)
volume_horz = Primitives.Volume()
volume_horz.surfaceProperty = volume_horz.TRANSPARENT
volume_horz.name = "In-focus-horizontal"
volume_horz.color = np.array([1,1,0])#np.array(Utils.metersToRGB(referenceWavelength))
volume_horz.opacity = 0.25
volume_horz.points = np.vstack([p_aft_horz,p_fore_horz])
volume_horz.data = vh_angles
self.append(volume_horz)
self.rawPupilSolidAngle = 0.5*(np.mean(vv_angles) + np.mean(vh_angles))
return (volume_vert, volume_horz)
def _findFocusingPoint(self,
theoreticalPoint,
angles = np.array([0, 90]),
tol = 1e-3):
"""
As the environment that the camera is placed can include mirrors
and refractive materials, the light path has to be calculated with
the ray tracing algorithm.
For the given point, four rays are cast - each at a border of the
entrance pupil. Their initial vector is defined as the one that reaches
the theoretical point (given).
Then, for each pair (the horizontal and the vertical), the
intersection of the ray paths is verified. The intersection point is
then the point in space where focusing is perfect.
Parameters
----------
theoreticalPoint : numpy.array (3)
This is the point in space where the camera would be imaging if it
were isolated (no mirrors, refractions, etc)
angles : numpy.array (N) [0, 90]
The angles (with relation to the optical axis) for analysis of
astigmatism in the system. The typical configuration performs the
analysis only on the lens xy and xz planes, respectively.
tol : float (1e-3)
The tolerance in finding the ray intersection. This value is kept
relatively high because in the case of astigmatism, the Y and Z
axes of the camera might not be aligned with the astigmatism axes,
which can cause the intersection not to be perfect. This value
still produces results comparable to the one found in Zeiss
datasheets
Returns
-------
pts : numpy.array (N,3)
Each point is the intersection of the marginal rays casted from
the intersection of the entrance pupil and a plane at an angle
determined by the parameter "angle" in the input.
angle : numpy.array (N)
The solid angle formed by the point and the entrance pupil
"""
pupilPoints = np.ones((2*len(angles),3))
pupilPoints = pupilPoints * self.lens.E
for n, angle in enumerate(angles):
angle = angle * np.pi / 180
pupilPoints[n*2] = (pupilPoints[n*2] +
self.lens.z*self.lens.Edim*np.cos(angle)/2 +
self.lens.y*self.lens.Edim*np.sin(angle)/2)
pupilPoints[n*2+1] = (pupilPoints[n*2 + 1] -
self.lens.z*self.lens.Edim*np.cos(angle)/2 -
self.lens.y*self.lens.Edim*np.sin(angle)/2)
# print "Entrance pupil\n", pupilPoints
""" Vectors going to the theoretical point """
vectors = Utils.normalize(theoreticalPoint - pupilPoints)
# print "Vectors\n", vectors
# print "Vecnorms\n", np.sqrt(np.sum(vectors*vectors,1))
""" Create the bundle for ray tracing """
rays = Primitives.RayBundle()
n = self.append(rays)
rays.append(vectors, pupilPoints, self.referenceWavelength)
rays.maximumRayTrace = 1.5 * Utils.norm(theoreticalPoint - self.lens.E)
rays.stepRayTrace = rays.maximumRayTrace
rays.trace()
self.remove(n-1)
""" Now run the bundle trying to find the intersection """
steps = np.size(rays.rayPaths, 0)
pts = np.empty((len(angles),3))
pupil_angle = np.empty(len(angles))
for step in range(1,steps):
p2 = rays.rayPaths[step]
p1 = rays.rayPaths[step-1]
v = p2 - p1
for n in range(len(angles)):
point = Utils.linesIntersection(v [[2*n,2*n+1]],
p1[[2*n,2*n+1]])
if (Utils.pointSegmentDistance(p1, p2, point) < tol).all():
pts[n] = point
planar_angle = np.arccos(np.dot(v[2*n],v[2*n+1])/
(np.linalg.norm(v[2*n])*
np.linalg.norm(v[2*n+1])))
pupil_angle[n] = (np.pi/4)*(planar_angle)**2
# print "Angle %3.4f, angle %3.4f, solid angle %1.4e" % (angles[n],
# planar_angle*180/np.pi,
# pupil_angle[n])
rays = None
return (pts, pupil_angle)
[docs] def virtualCameras(self, centeronly = True):
"""
Returns an assembly composed of cameras at the position and orientation
defined by the original camera mapping. E.g. if a camera is looking
through a mirror, the virtualCamera will be the mirror image of the
camera.
Parameters
----------
centeronly : boolean
If the camera mapping resolution has created more than a single
mapping matrix (>2), setting this value to True makes the routine
create only one camera (for the center mapping). Otherwise it will
create as many cameras as mapping matrices.
Returns
-------
virtualCameras : pyvsim.Assembly
The cameras within this assembly are copies of the original camera
only with position and orientation changed (and carcass color), so
they are completely functional.
Care should be taken, as having too many cameras requires a lot of
memory (mappings, sensor data is stored in each camera).
"""
if self.mapping is None:
raise ValueError("No mapping available, " +
"could not create virtual cameras")
return None
phantomPrototype = copy.deepcopy(self)
phantomPrototype.clearData()
phantomPrototype.parent = None
phantomPrototype.body.color = [0.5,0,0]
phantomPrototype.body.opacity = 0.2
phantomPrototype.lens.color = [0.5,0.5,0.5]
phantomPrototype.lens.opacity = 0.2
# print phantomPrototype.x
# print phantomPrototype.y
# print phantomPrototype.z
# print phantomPrototype.lens.x
# print phantomPrototype.lens.y
# print phantomPrototype.lens.z
phantomPrototype.lens.alignTo(phantomPrototype.x, phantomPrototype.y)
for item in phantomPrototype:
item.parent = phantomPrototype
phantomAssembly = Primitives.Assembly()
# sy = self.sensor.dimension[1]
# sz = self.sensor.dimension[2]
# Matrix to go from sensor coordinates to sensor
# local coordinates
MT = np.array([[ 0 , 0, 1],
[ 0 , 1, 0],
[ 1 , 0, 0]])
if centeronly:
rangei = [np.round(np.size(self.mapping,0)/2)]
rangej = [np.round(np.size(self.mapping,1)/2)]
else:
rangei = range(np.size(self.mapping,0))
rangej = range(np.size(self.mapping,1))
for i in rangei:
for j in rangej:
phantom = copy.deepcopy(phantomPrototype)
M = self.mapping[i,j,:,:]
[_,__,V] = np.linalg.svd(M)
pinholePosition = (V[-1] / V[-1][-1])[:-1]
phantom.translate(pinholePosition -
phantom.lens.PinholeFore)
# Transform the DLT matrix (that originally goes from global
# coordinates to sensor coords) to local sensor coordinates
MTM = np.dot(MT, M[:,:-1])
[_,Qm] = Utils.KQ(MTM)
phantom.mapping = M
phantom.alignTo(Qm[0],-Qm[1],None,
phantom.lens.PinholeFore, 1e-3)
phantomAssembly.append(phantom)
return phantomAssembly
[docs]class Seeding(Primitives.Assembly):
def __init__(self):
Primitives.Assembly.__init__(self)
self.name = 'Seeding ' + str(self._id)
self.volume = Primitives.Volume()
self.cloud = Primitives.Points()
self += self.volume
self += self.cloud
self.density = 1e11
self._particles = None
self._diameters = None
self.volume.color = [0.9,0.9,0.9]
self.volume.surfaceProperty = self.volume.TRANSPARENT
[docs] def refractiveIndex(self, wavelength = 532e-9):
"""
Returns the index of refraction of the material given the wavelength
(or a list of them)
Parameters
----------
wavelength : scalar or numpy.array
The wavelength of the incoming light given in *meters*
Returns
-------
refractiveIndex : same dimension as wavelength
The index of refraction
"""
return 1.45386 #self.material.refractiveIndex(wavelength)
@property
[docs] def bounds(self):
"""
This signals the ray tracing implementation that no attempt should be
made to intersect rays with the seeding (it's mapped differently)
"""
return None
@property
def points(self): return self.cloud.points
@points.setter
def points(self, particles):
self.cloud.points = particles
[xmin, ymin, zmin] = np.min(particles,0)
[xmax, ymax, zmax] = np.max(particles,0)
self.volume.points = np.array([[xmin,ymax,zmax],
[xmin,ymin,zmax],
[xmin,ymin,zmin],
[xmin,ymax,zmin],
[xmax,ymax,zmax],
[xmax,ymin,zmax],
[xmax,ymin,zmin],
[xmax,ymax,zmin]])
@property
def diameters(self): return self._diameters
@diameters.setter
def diameters(self, diams):
if np.size(diams) != np.size(self.cloud.points,0):
raise ValueError("The diameter array must be the same size than"+
" the particle position array.")
self._diameters = diams
def seed(self):
o = self.volume.points[0]
v1 = self.volume.points[1] - self.volume.points[0]
v2 = self.volume.points[3] - self.volume.points[0]
v3 = self.volume.points[4] - self.volume.points[0]
volume = np.linalg.norm(np.dot(v1,np.cross(v2,v3)))
npts = volume * self.density
pts = np.random.rand(npts,3)
pts = o + (np.einsum("i,j->ij",pts[:,0],v1) +
np.einsum("i,j->ij",pts[:,1],v2) +
np.einsum("i,j->ij",pts[:,2],v3))
diams = np.random.rand(npts)
diams = self._sizeDistribution(diams)
self.cloud.points = pts
self._diameters = diams
def _sizeDistribution(self, seeds):
diam = np.linspace(0,4,100)
pdf = gammainc(13.9043,10.9078*diam)**0.2079
interpolator = interp1d(pdf, diam)
return interpolator(seeds)*1e-6
def scatteredEnergy(self, lineofsight, lightvector, solidangle,
wavelength = 532e-9, polarization = 0):
lightintensity = Utils.norm(lightvector)
# lightvecnorm = np.einsum("ij,i->ij", lightvector, 1/lightintensity)
scatterangle = np.arccos(np.sum(lineofsight*lightvector,1)/
lightintensity)
# Replacing nans by suitable value
scatterangle[np.isnan(scatterangle)] = 9999
minangle = np.min(scatterangle)
scatterangle[scatterangle == 9999] = minangle
# plt.plot(scatterangle)
# plt.show()
print "Diameter range ", np.min(self.diameters), np.max(self.diameters)
if np.min(self.diameters) != np.max(self.diameters):
diams = np.linspace(np.min(self.diameters),
np.max(self.diameters),
500)
else:
diams = np.array([np.min(self.diameters),
np.max(self.diameters)+1e-6])
print "Angle range ", np.min(scatterangle), np.max(scatterangle)
plt.plot(scatterangle)
plt.show()
if np.min(scatterangle) != np.max(scatterangle):
angles = np.linspace(np.min(scatterangle),
np.max(scatterangle),
30)
else:
angles = np.array([np.min(scatterangle),
np.max(scatterangle)+1e-6])
(s1,s2) = MieUtils.mieScatteringCrossSections(self.refractiveIndex(wavelength),
diams,
wavelength,
angles)
scs = (s1 * (np.cos(polarization)**2) +
s2 * (1 - np.cos(polarization)**2))
interpolant = RectBivariateSpline(angles,
diams,
scs,
kx = 1, ky = 1)
scs = interpolant.ev(scatterangle, self.diameters)
return scs*lightintensity*solidangle
[docs]class CalibrationPlate(Seeding):
def __init__(self, sidelength = 0.2):
Seeding.__init__(self)
[X,Y] = np.meshgrid(np.linspace(-sidelength/2,sidelength/2,100),
np.linspace(-sidelength/2,sidelength/2,100))
Z = 0*np.ones(np.size(X))
self.points = np.vstack((X.ravel(),Y.ravel(),Z)).T
self.points = np.vstack([self.points,
self.points + np.array([1e-3,1e-3,1e-3])])
# print X.shape, Y.shape, Z.shape, self.points.shape
self.diameters = (2.2e-6*np.ones(np.size(self.points[:,0])) +
1e-10*np.random.rand(np.size(self.points[:,0])))
[docs]class Laser(Primitives.Assembly):
def __init__(self):
Primitives.Assembly.__init__(self)
self.name = 'Laser '+str(self._id)
self.transientFields.extend(["profileInterpolator"])
self.body = None
self.rays = None
self.volume = None
# Plotting properties
self.color = [0.1,0.1,0.1]
self.opacity = 1.000
self.dimension = np.array([1.060, 0.250, 0.270])
self.wavelength = 532e-9
# Beam properties
self._profile = None
self.profileInterpolator = None
self._pulseEnergy = 0.500
self._beamDivergence = np.array([0.0005, 0.25])
self._beamDiameter = 0.01#0.018
[X,Y] = np.meshgrid(np.arange(-6,7),
np.arange(-6,7))
self.profile = np.exp(-0.15*(X**2+Y**2))
# Ray tracing characteristics
self.usefulLength = np.array([1, 3])
self.usefulLengthDiscretization = 0.1
self.safeEnergyDensity = 5e-3 #1e-3
self.safetyTracingRays = [7,5]
self.safetyTracingStrategy = [[7,7],
[15,0.05],
[100,100]]
self._positionComponents()
@property
def profile(self): return self._profile
@profile.setter
def profile(self, profileMatrix):
self._profile = profileMatrix
self.clearData()
@property
def pulseEnergy(self): return self._pulseEnergy
@pulseEnergy.setter
def pulseEnergy(self, power):
self._pulseEnergy = power
self.clearData()
@property
def beamDivergence(self): return self._beamDivergence
@beamDivergence.setter
def beamDivergence(self, div):
self._beamDivergence = div
self.clearData()
@property
def beamDiameter(self): return self._beamDiameter
@beamDiameter.setter
def beamDiameter(self, diam):
self._beamDiameter = diam
self.clearData()
@property
[docs] def bounds(self):
"""
The laser participates in the ray tracing procedure only if it lays
the volumes representing its light beam
"""
if self.volume is not None:
return self.volume.bounds
else:
return None
def display(self):
plt.figure(facecolor = [1,1,1])
plt.axis("equal")
plt.grid(True, which = "both", axis = "both")
plt.title("Laser beam profile - J/m^2")
imgplot = plt.imshow(self.profile)
imgplot.set_interpolation('none')
plt.colorbar()
plt.show()
def clearData(self):
energy = np.sum(self._profile) * (self.beamDiameter**2 /
np.size(self._profile))
# print "Energy", energy
multiplier = self.pulseEnergy / energy
# print "Multiplier", multiplier
self._profile = self._profile * multiplier
self.profileInterpolator = RectBivariateSpline(
np.linspace(-1,1,np.size(self._profile,1)),
np.linspace(-1,1,np.size(self._profile,1)),
self._profile,
kx = 1,
ky = 1)
if self.volume is not None:
self.remove(self.volume)
self.volume = None
self._positionComponents()
Primitives.Assembly.clearData(self)
def _positionComponents(self):
"""
TODO
"""
if self.body is None:
self.body = Primitives.Volume(self.dimension)
self.append(self.body)
self.body.translate(-self.x*self.dimension[0])
self.body.color = self.color
self.body.opacity = self.opacity
self.rays = Primitives.RayBundle()
self.append(self.rays)
vectors = np.tile(self.x,(4,1))
# Divergence in the xz plane
vectors = np.vstack([Utils.rotateVector(vectors[:2],
self.beamDivergence[1]/2,
self.y),
Utils.rotateVector(vectors[2:],
-self.beamDivergence[1]/2,
self.y)])
# Divergence in the xy plane
vectors = np.vstack([Utils.rotateVector(vectors[0],
-self.beamDivergence[0]/2,
self.z),
Utils.rotateVector(vectors[[1,2]],
self.beamDivergence[0]/2,
self.z),
Utils.rotateVector(vectors[3],
-self.beamDivergence[0]/2,
self.z)])
# Position the four main rays with some spacing, according to the
# initial beam diameter
positions = self.origin + (self.beamDiameter/2 *
np.array([-self.y -self.z,
+self.y -self.z,
+self.y +self.z,
-self.y +self.z]))
self.rays.append(vectors, positions, self.wavelength)
[docs] def trace(self):
"""
Creates an assembly containing volumes representing the laser
propagation. The volumes are created from usefulLength[0] to
usefulLength[1], which is only a way to calculate less elements
and reduce calculation costs, not an energy relation.
Important parameters
- Laser.usefulLength : the useful region of the laser sheet
- Laser.usefulLengthDiscretization : the length of the discretization
volume. Shorter elements provide better interpolation, but make
computation extremely costly.
"""
self.rays.maximumRayTrace = self.usefulLength[0]
self.rays.stepRayTrace = self.usefulLength[0]
self.rays.trace(tracingRule = self.rays.TRACING_FOV)
start = np.size(self.rays.rayPaths,0) - 1
self.rays.maximumRayTrace = self.usefulLength[1]
self.rays.stepRayTrace = self.usefulLengthDiscretization
self.rays.trace(tracingRule = self.rays.TRACING_FOV, restart= True)
end = np.size(self.rays.rayPaths,0)
self.volume = Primitives.Assembly()
self.append(self.volume)
pts = np.array([[-1,-1],
[+1,-1],
[+1,+1],
[-1,+1]])
for n in range(start, end-1):
vol = Primitives.Volume(fastInit = True)
vol.surfaceProperty = vol.TRANSPARENT
vol.points = np.vstack([self.rays.rayPaths[n],
self.rays.rayPaths[n+1]])
S1 = Utils.quadArea(self.rays.rayPaths[n,0],
self.rays.rayPaths[n,1],
self.rays.rayPaths[n,2],
self.rays.rayPaths[n,3])
S2 = Utils.quadArea(self.rays.rayPaths[n+1,0],
self.rays.rayPaths[n+1,1],
self.rays.rayPaths[n+1,2],
self.rays.rayPaths[n+1,3])
vecs = Utils.normalize(self.rays.rayPaths[n+1]-
self.rays.rayPaths[n])
vol.data = np.hstack((np.vstack((pts,pts)),
np.vstack((vecs,vecs)),
np.vstack((np.ones((4,1))*S1,
np.ones((4,1))*S2))))
vol.color = Utils.metersToRGB(self.wavelength)
vol.opacity = 0.1
self.volume += vol
[docs] def illuminate(self, pts):
"""
Given a set of points in space, this method calculates the light
intensity (in :math:`J/m^2`) and direction produced by the laser.
Parameters
----------
pts : numpy.array (N,3)
A number of points in space
Returns
-------
intensity : numpy.array (N,3)
A vector which norm is the light intensity (in :math:`J/m^2`) pointing to
the direction that the light emanating from the laser is
"""
result = np.zeros((np.size(pts,0),np.size(self.volume[0].data,1)))
for vol in self.volume:
result += vol.interpolate(pts)
[i,j] = result[:,:2].T
vecs = Utils.normalize(result[:,2:5])
S = result[:,5]
intensity = self.profileInterpolator.ev(i, j) * (self.beamDiameter ** 2/
S)
intensity[S == 0] = 0
return np.einsum("ij,i->ij",vecs, intensity)
[docs] def traceReflections(self):
"""
POC implementation of a calculation of laser safety distances
"""
# Calculate how many rays will be generated
n1 = self.safetyTracingRays[0]
n2 = self.safetyTracingRays[1]
nrays = n1*n2
# Create a grid of positions for the rays to start
x = np.linspace(-1, +1, n2)
y = np.linspace(-1, +1, n1)
[X,Y] = np.meshgrid(x,y)
points = np.vstack([Y.ravel(),
X.ravel(),
np.zeros(nrays)]).T
# Calculate where these rays should be in the laser output
physicalPoints = (np.einsum("i,j->ij",points[:,0],self.y*
self.beamDiameter*0.5*(n1-1)/(n1-2)) +
np.einsum("i,j->ij",points[:,1],self.z*
self.beamDiameter*0.5*(n2-1)/(n2-2))).squeeze()
# physicalPoints = physicalPoints * (self.beamDiameter * (nside-1) /
# (2*(nside-2)))
physicalPoints = physicalPoints + self.origin
# For each ray, calculate their corresponding initial propagation vector
vectors = Utils.quadInterpolation(points,
np.array([[-1,-1,0],
[+1,-1,0],
[+1,+1,0],
[-1,+1,0]]),
self.rays.initialVectors)
vectors = Utils.normalize(vectors)
bundle = Primitives.RayBundle()
bundle.append(vectors,
physicalPoints,
self.wavelength)
self.append(bundle)
restart = False
tic = Utils.Tictoc()
for length, step in self.safetyTracingStrategy:
print "Tracing up to length %f with step %f" % (length, step)
bundle.maximumRayTrace = length
bundle.stepRayTrace = step
tic.tic()
bundle.trace(tracingRule = bundle.TRACING_LASER_REFLECTION,
restart = restart)
restart = True
tic.toc()
# initial_density = self.pulseEnergy / (self.beamDiameter**2/
# ((n1-1)*(n2-1)))
# initial_energy = self.pulseEnergy / ((n1-2)*(n2-2))
energyDensity = np.zeros((n1,n2))
initialEnergyDensity = self.profileInterpolator.ev(points[:,0],
points[:,1])
initialEnergyDensity = np.reshape(initialEnergyDensity,(n1,n2))
referenceArea = self.beamDiameter**2 / ((n1-2)*(n2-2))
maxEnergyDensity = np.max(initialEnergyDensity)
# print "maxEd %s" % maxEnergyDensity
# We will create a connectivity map for a rectangle with side n-1, as
# we will take the centerpoint for each 4 rays
n = np.arange((n1-1)*(n2-1))
n = np.reshape(n,(n1-1,n2-1))
cts = np.empty(((n2-2)*(n1-2),4))
k = 0
for i in np.arange(n1-2):
for j in np.arange(n2-2):
cts[k] = [n[i,j], n[i,j+1], n[i+1,j+1], n[i+1,j]]
k += 1
cts = cts.astype(int)
color = np.empty((bundle.steps+1,nrays,3))
# print bundle.steps
# print color.shape
for n in range(np.size(bundle.rayPaths,0)):
# arrange the rays in the grid they originally were
pts = np.reshape(bundle.rayPaths[n],(n1,n2,3))
# find the mean points of each 4 rays
pts = 0.25*(pts[1:,:-1] + pts[:-1,:-1] + pts[1:,1:] + pts[:-1,1:])
# reshape in a list of points
pts = np.reshape(pts,(-1,3))
# calculate the area of each of the new quads (using our nice
# connectivity list)
area = Utils.quadArea(pts[cts[:,0]],
pts[cts[:,1]],
pts[cts[:,2]],
pts[cts[:,3]])
area = np.reshape(area,(n1-2,n2-2))
energyDensity[1:-1,1:-1] = (initialEnergyDensity[1:-1,1:-1] *
referenceArea / area)
e = energyDensity.ravel()
# print "E %s" % np.max(e)
color[n] = Utils.jet(np.log10(e+1e-5),
np.log10(self.safeEnergyDensity),
np.log10(maxEnergyDensity),
saturationIndicator = True)
for n,line in enumerate(bundle):
line.color = color[:,n]
line.width = 2
# The rays at the margin (that receive density zero) are then "erased"
toerase = np.reshape(np.arange(n1*n2),(n1,n2))
toerase[1:-1,1:-1] = 0
toerase = np.nonzero(toerase.ravel())[0]
# This trick does not work for
# ray[0,0], so:
bundle[0].opacity = 0
for i in toerase:
bundle[i].opacity = 0
if __name__=='__main__':
import System
import copy
import Library
tic = Utils.Tictoc()
c = Camera()
c.lens.focusingDistance = 1#.9695 #0.9725
c.lens.aperture = 2
c.mappingResolution = [2,2]
c.lens.distortionParameters = np.array([0,0,0,0,
0,0,0,0,
0,0,0,0])
# Put the sensor at the position [0,0,0] to make verification easier
c.translate(-c.x*c.sensorPosition)
scheimpflug = 0*-0.75*np.pi/180
c.setScheimpflugAngle(scheimpflug, c.y)
# c.rotate(-scheimpflug, c.y, c.x*c.sensorPosition)
# c.lens.rotate(scheimpflug, c.y, c.x*c.sensorPosition)
l = Laser()
# l.beamDivergence = np.array([0.5e-3, 0.25])
l.beamDivergence = np.array([-7e-3, 4.596e-2])
l.pulseEnergy = 5.005# 0.1
l._positionComponents()
l.alignTo(-l.x, l.y, -l.z, np.array([0.6,0,0]))
l.translate(np.array([0,0.5,0]))
l.usefulLength = np.array([0.55, 0.8])
l.usefulLengthDiscretization = 0.1
l.safetyTracingRays = [10,100]
l.safetyTracingStrategy = [[4,.01]]
v = Primitives.Volume()
# v.rotate(np.pi/9, v.z)
v.opacity = 0.1
v.dimension = np.array([0.3, 0.3, 0.3])
v.material = Library.IdealMaterial()
v.material.value = 1.33
v.surfaceProperty = v.TRANSPARENT
v.translate(np.array([0.35,0.5,0]))
v2 = Primitives.Volume()
v2.dimension = np.array([0.0001, 0.3, 0.3])
v2.surfaceProperty = v2.MIRROR
# v2.surfaceProperty = v.TRANSPARENT
v2.material = Library.IdealMaterial()
v2.material.value = 1
v2.translate(np.array([0.5,0,0]))
v2.rotate(-np.pi/4,v2.z)
# seed = Seeding()
# seed.points = (np.array([0.5,0.5,0]) +
# 0.02*Primitives.Volume.PARAMETRIC_COORDS)
# seed.density = 1e11 / 800*3
# seed.seed()
seed = CalibrationPlate()
seed.translate(np.array([0.5,0.5,0]))
seed.rotate(1.570796327,np.array([1,0,0]))
environment = Primitives.Assembly()
environment += seed
environment += c
# environment += v
environment += v2
environment += l
# Some geometrical transformations to make the problem more interesting
c.rotate(90*np.pi/180,c.lens.x)
# environment.rotate(np.pi/0.1314, c.x)
# environment.rotate(np.pi/27, c.y)
# environment.rotate(np.pi/2.1, c.z)
npts = np.size(seed.points,0)
print "Laser sheet tracing"
tic.tic()
l.trace()
tic.toc()
print "Laser sheet safety tracing"
tic.tic()
l.traceReflections()
tic.toc()
print "\nCamera parameter determination"
tic.tic()
c.initialize()
tic.toc()
print c.virtualApertureArea / (np.pi*(0.05/c.lens.aperture)**2)
"""Calculate the position of each point in the sensor"""
(uv, w, duvw, lineofsight, imdim, sldangle) = c.mapPoints(seed.points)
"""Calculate the incoming light"""
print "\nIllumination phase"
tic.tic()
lightvector = l.illuminate(seed.points)
tic.toc(np.size(seed.points,0))
tic.tic()
energy = seed.scatteredEnergy(lineofsight = lineofsight,
lightvector = lightvector,
solidangle = sldangle,
wavelength = 532e-9,
polarization = 0)
tic.toc(npts)
tic.tic()
c.sensor.recordParticles(uv,
energy,
532e-9,
np.abs(imdim))
tic.toc(npts)
print "\nSaving image"
tic.tic()
c.sensor.save("test01.tif")
tic.toc()
c.sensor.display("jet")
vc = c.virtualCameras(True)
environment += vc
#
System.plot(environment)