SimpleITK 3Deuler angles rotation volumetric data - python

I want to rotate my volumetric CT data using the euler angles x, y and z. For this, I use SimpleITK. I have read the question from Dr. Jessop - simpleitk-rotation-of-volumetric-data-e-g-mri and I think I have the same problem that my orientation/direction is not an identity matrix. The direction namely is:
0.08716564279125966, 0.0, -0.9961938319005929, 0.9961938319005927, 6.633444000000004e-17, 0.08716564279125968, 0.0, -1.0, 6.12303124808918e-17
However, the solution Dr. Jessop has found is by using an axis-angle orientation so that he can rotate around the z axis only. I want to rotate around all axes by using Euler angles. How can I achieve this?
P.S. I would have commented on Dr.Jessops question to ask it, but I don't have enough reputation points for this.
The code of dr. Jessop:
# This function is from https://github.com/rock-learning/pytransform3d/blob/7589e083a50597a75b12d745ebacaa7cc056cfbd/pytransform3d/rotations.py#L302
def matrix_from_axis_angle(a):
""" Compute rotation matrix from axis-angle.
This is called exponential map or Rodrigues' formula.
Parameters
----------
a : array-like, shape (4,)
Axis of rotation and rotation angle: (x, y, z, angle)
Returns
-------
R : array-like, shape (3, 3)
Rotation matrix
"""
ux, uy, uz, theta = a
c = np.cos(theta)
s = np.sin(theta)
ci = 1.0 - c
R = np.array([[ci * ux * ux + c,
ci * ux * uy - uz * s,
ci * ux * uz + uy * s],
[ci * uy * ux + uz * s,
ci * uy * uy + c,
ci * uy * uz - ux * s],
[ci * uz * ux - uy * s,
ci * uz * uy + ux * s,
ci * uz * uz + c],
])
# This is equivalent to
# R = (np.eye(3) * np.cos(theta) +
# (1.0 - np.cos(theta)) * a[:3, np.newaxis].dot(a[np.newaxis, :3]) +
# cross_product_matrix(a[:3]) * np.sin(theta))
return R
def resample(image, transform):
"""
This function resamples (updates) an image using a specified transform
:param image: The sitk image we are trying to transform
:param transform: An sitk transform (ex. resizing, rotation, etc.
:return: The transformed sitk image
"""
reference_image = image
interpolator = sitk.sitkLinear
default_value = 0
return sitk.Resample(image, reference_image, transform,
interpolator, default_value)
def get_center(img):
"""
This function returns the physical center point of a 3d sitk image
:param img: The sitk image we are trying to find the center of
:return: The physical center point of the image
"""
width, height, depth = img.GetSize()
return img.TransformIndexToPhysicalPoint((int(np.ceil(width/2)),
int(np.ceil(height/2)),
int(np.ceil(depth/2))))
def rotation3d(image, theta_z, show=False):
"""
This function rotates an image across each of the x, y, z axes by theta_x, theta_y, and
theta_z degrees
respectively
:param image: An sitk MRI image
:param theta_x: The amount of degrees the user wants the image rotated around the x axis
:param theta_y: The amount of degrees the user wants the image rotated around the y axis
:param theta_z: The amount of degrees the user wants the image rotated around the z axis
:param show: Boolean, whether or not the user wants to see the result of the rotation
:return: The rotated image
"""
theta_z = np.deg2rad(theta_z)
euler_transform = sitk.Euler3DTransform()
print(euler_transform.GetMatrix())
image_center = get_center(image)
euler_transform.SetCenter(image_center)
direction = image.GetDirection()
print(direction)
axis_angle = (direction[2], direction[5], direction[8], theta_z)
np_rot_mat = matrix_from_axis_angle(axis_angle)
euler_transform.SetMatrix(np_rot_mat.flatten().tolist())
resampled_image = resample(image, euler_transform)
if show:
slice_num = int(input("Enter the index of the slice you would like to see"))
plt.imshow(sitk.GetArrayFromImage(resampled_image)[slice_num])
plt.show()
return resampled_image
To get the rotationmatrix from the euler angle method, this code could be used:
def matrix_from_euler_xyz(e):
"""Compute rotation matrix from xyz Euler angles.
Intrinsic rotations are used to create the transformation matrix
from three concatenated rotations.
The xyz convention is usually used in physics and chemistry.
Parameters
----------
e : array-like, shape (3,)
Angles for rotation around x-, y'-, and z''-axes (intrinsic rotations)
Returns
-------
R : array-like, shape (3, 3)
Rotation matrix
"""
alpha, beta, gamma = e
# We use intrinsic rotations
Qx = matrix_from_angle(0, alpha)
Qy = matrix_from_angle(1, beta)
Qz = matrix_from_angle(2, gamma)
R = Qx.dot(Qy).dot(Qz)
return R
However, the orientation should still be incorporated. Does anyone know how to do this?

This can be performed using SimpleITK using VersorRigid3DTransform and passing it into the resampleitkfilter, which I believe is what this code is doing in a way.
I would suggest using scipy.transform.rotation module to pass in a versor into the rigid3Dtransform then set your direction from the input to output accordingly.
I 'think' this will work even with your off-unit directions but as always with 3D affine transforms this might be missing something that brighter mathematicians can critique:
r = R.from_euler('zyx', [90, 45, 30], degrees=True)
orientation = r.as_quat()
transform = sitk.VersorRigid3DTransform()
translation = sitk.TranslationTransform(3, (x,y,z))
transform.SetTranslation(translation.GetOffset())
#get rot_center from your center of image function as tuple (x,y,z)
rotation = sitk.VersorTransform([orientation[0], orientation[1], orientation[2], orientation[3]],rot_center)
transform.SetRotation(rotation.GetVersor())
transform.SetCenter(rotation.GetCenter())
size = img.GetSize()
bounds = list()
for x in [0, size[0]]:
for y in [0, size[1]]:
for z in [0, size[2]]:
bounds.append(img.TransformIndexToPhysicalPoint((x, y, z)))
# get the physical position of the bounds given transform
trans_bounds = list()
for b in bounds:
trans_bounds.append(transform.TransformPoint((b[0], b[1], b[2])))
trans_bounds = np.array(trans_bounds)
xmin = np.min(trans_bounds.T[0])
xmax = np.max(trans_bounds.T[0])
ymin = np.min(trans_bounds.T[1])
ymax = np.max(trans_bounds.T[1])
zmin = np.min(trans_bounds.T[2])
zmax = np.max(trans_bounds.T[2])
output_origin = (xmin, ymin, zmin)
res_all = (0.5, 0.5, 0.5)
res = res_all[0]
output_size = np.array(
[int(np.round((xmax - xmin) / res)), int(np.round((ymax - ymin) / res)), int(np.round((zmax - zmin) / res))])
output_size = output_size.astype(int)
resampleFilter = sitk.ResampleImageFilter()
resampleFilter.SetTransform(transform.GetInverse())
resampleFilter.SetInterpolator(sitk.sitkLinear)
resampleFilter.SetSize(output_size.tolist())
resampleFilter.SetOutputOrigin(output_origin)
resampleFilter.SetOutputSpacing(res_all)
resampleFilter.SetOutputDirection(img.GetDirection())
resampleFilter.SetDefaultPixelValue(0.0)

I solved it (with the help of zivy from the itk discourse
My answer is this:
# The function which is used to rotate (and make the 3D image isotropic) using euler angles
def rotation3d(image, theta_x, theta_y, theta_z, output_spacing = None, background_value=0.0):
"""
This function rotates an image across each of the x, y, z axes by theta_x, theta_y, and theta_z degrees
respectively (euler ZXY orientation) and resamples it to be isotropic.
:param image: An sitk 3D image
:param theta_x: The amount of degrees the user wants the image rotated around the x axis
:param theta_y: The amount of degrees the user wants the image rotated around the y axis
:param theta_z: The amount of degrees the user wants the image rotated around the z axis
:param output_spacing: Scalar denoting the isotropic output image spacing. If None, then use the smallest
spacing from original image.
:return: The rotated image
"""
euler_transform = sitk.Euler3DTransform (image.TransformContinuousIndexToPhysicalPoint([(sz-1)/2.0 for sz in image.GetSize()]),
np.deg2rad(theta_x),
np.deg2rad(theta_y),
np.deg2rad(theta_z))
# compute the resampling grid for the transformed image
max_indexes = [sz-1 for sz in image.GetSize()]
extreme_indexes = list(itertools.product(*(list(zip([0]*image.GetDimension(),max_indexes)))))
extreme_points_transformed = [euler_transform.TransformPoint(image.TransformContinuousIndexToPhysicalPoint(p)) for p in extreme_indexes]
output_min_coordinates = np.min(extreme_points_transformed, axis=0)
output_max_coordinates = np.max(extreme_points_transformed, axis=0)
# isotropic ouput spacing
if output_spacing is None:
output_spacing = min(image.GetSpacing())
output_spacing = [output_spacing]*image.GetDimension()
output_origin = output_min_coordinates
output_size = [int(((omx-omn)/ospc)+0.5) for ospc, omn, omx in zip(output_spacing, output_min_coordinates, output_max_coordinates)]
output_direction = [1,0,0,0,1,0,0,0,1]
output_pixeltype = image.GetPixelIDValue()
return sitk.Resample(image,
output_size,
euler_transform.GetInverse(),
sitk.sitkLinear,
output_origin,
output_spacing,
output_direction,
background_value,
output_pixeltype)

Related

Undo the rotation by keeping the rotated object in position

I am rotating an object about an arbitrary axis with a given angle using (Link) following rotation matrix:
def rotate_object(self, direction_vector, origin_point, point_to_rotate, angle):
angle = np.radians(angle)
a, b, c = origin_point
x, y, z = point_to_rotate
if direction_vector == 0:
u, v, w = [1.0, 0.0, 0.0] # Rotate in +x direction
elif direction_vector == 1:
u, v, w = [0.0, 1.0, 0.0] # Rotate in +y direction
elif direction_vector == 2:
u, v, w = [-0.1027, -0.1525, 0.9829] # Rotate in +z direction
cos = np.cos(angle)
sin = np.sin(angle)
qx = (a*(v**2 + w**2) -u*(b*v + c*w - u*x - v*y - w*z)) * (1-cos) + x*cos + (-c*v + b*w - w*y + v*z) * sin
qy = (b*(u**2 + w**2) -v*(a*u + c*w - u*x - v*y - w*z)) * (1-cos) + y*cos + (c*u - a*w + w*x - u*z) * sin
qz = (c*(u**2 + v**2) -w*(a*u + b*v - u*x - v*y - w*z)) * (1-cos) + z*cos + (-b*u + a*v - v*x + u*y) * sin
return qx, qy, qz
The rotation of the object about a desired axis with a given angle works as expected. But I want to undo the coordinate system after every rotation to the original state by keeping the object on its rotated position. How could I do that?
Thank you!
Suppose you have two rotation axis: axis1 and axis2 which are 3D lines connected at a point (they intersect at a point).
Suppose the axis form a hierarchy, like the turret of a tank which can rotate around axis1 and the cannon mounted on the turret which can rotate around axis2.
The goal is to compute rotation of the cannon given the rotation of the turret or... compute rotation around axis2 given rotation around axis1.
Let's define your transform function as F(axis, angle, p)
Rotation around axis1 can be computed as F(axis1, angle1, p). Take into account that axis1 is "parent" of axis2. In our example that means that when turret rotates also the cannon rotates. So all points from turret AND cannon are rotated like F(axis1, angle1, p).
Rotation around axis2 can be defined as:
F(axis1, angle1, F(axis2, angle2, p))
That is a conposition of transforms in which we first apply rotation around axis2 and then around axis1. Which means that all points of the cannon are transformed first and then they transform according to turret transform.
If you follow this receipt, composition of F(a1,F(a2,F(a3, ...))), you will be able to animate hierarchical models using F.

NumPy FFT producing off centre output

TL;DR: NumPy FFT creates non uniform output when output is wanted to be uniform. I want the output to be a uniform corona.
I am trying to eventually run a Gerchberg-Saxton phase retrieval algorithm. I have been trying to make sure that I understand how FFT works in NumPy. I have used fftshift to create the correct looking output but the image does not have uniform intensity afterwards.
My input image is a circle, output should be a coronagraph looking thing from the circle aperture. I am trying to reproduce the results detailed in https://www.osapublishing.org/optica/fulltext.cfm?uri=optica-2-2-147&id=311836#articleSupplMat.
My algorithm to produce the error:
Initial image, f
FT(f)
x exp ( i phase_mask)
IFT(FT(f)x exp( i phase_mask)
Happy to clear anything up.
import numpy as np
import matplotlib.pyplot as plt
#Create 'pixels' for circle
pixels = 400
edge = np.linspace(-10, 10, pixels)
xv, yv = np.meshgrid(edge, edge)
def circle(x, y, r):
'''
x, y : dimensions of grid to place circle on
r : radius
Function defines aperture
'''
x0 = 0
y0 = 0
return np.select([((x-x0)**2+(y-y0)**2)>=r**2,
((x-x0)**2+(y-y0)**2)<r**2],
[0,
1.])
#Create input and output images
radius = 4
input_img = circle(xv, yv, radius)
constraint_img = xcircle(xv, yv, radius)
img = input_img
constraint = 1 - img
max_iter = 10
re,im = np.mgrid[-1:1:400j, -1:1:400j] #Creates grid of values, 400=pixels
mask = 2*np.angle(re + 1j*im) #Gets angle from centre of grid
mask_i = mask
#Initial focal plane field, F. Initial image f.
f = np.sqrt(img)
F = np.fft.fftshift(np.fft.fft2(f)) * np.exp(mask * 1j) #Focal plane field
F_1 = F
am_f = np.abs(F_1) #Initial amplitude
g = np.fft.ifft2(F)
mask = np.angle(F/(F_1+1e-18)) #Final phase mask
recovery = (np.fft.ifft2(F*np.exp(-1j * mask)))
im3 = plt.imshow(np.abs(g)**2, cmap='gray')
plt.title('Recovered image')
plt.tight_layout()
plt.show()
plt.imshow(mask_i)
plt.colorbar()
plt.show()
Your issue is in this bit of code:
pixels = 400
edge = np.linspace(-10, 10, pixels)
as well as this one:
re,im = np.mgrid[-1:1:400j, -1:1:400j]
Because you use fftshift*, you need the origin to be at pixels//2. However, you don't sample the origin at all, it is in between two samples.
* You should really be using ifftshift instead, which moves the origin from pixels//2 to 0. fftshift moves the origin from 0 to pixels//2. For an even number of samples, these two do the same thing though.
To properly sample the origin, create edge as follows:
edge = np.linspace(-10, 10, pixels, endpoint=False)
We now see that edge[pixels//2] is equal to 0.
For np.mgrid there's no equivalent option. You will have to do this manually by creating one more sample, then deleting the last sample:
re,im = np.mgrid[-1:1:401j, -1:1:401j] #Creates grid of values, 400=pixels
mask = 2*np.angle(re + 1j*im) #Gets angle from centre of grid
mask = mask[:-1, :-1]
With these two changes, you will see a symmetric output.

OpenCV - Correctly recovering the pose and landmark positions from 2d image points

I'm trying to do 3D scene reconstruction and camera pose estimation on video input, however the camera positions are not matching what I am seeing in the video.
Here is the code I wrote to recover the pose and landmark positions
def SfM(self, points1, points2):
x = 800 / 2
y = 600 / 2
fov = 80 * (math.pi / 180)
f_x = x / math.tan(fov / 2)
f_y = y / math.tan(fov / 2)
# intrinsic camera matrix
K = np.array([[f_x, 0, x],
[0, f_y, y],
[0, 0, 1]])
#find fundamental matrix
E, mask = cv2.findFundamentalMat(np.float32(points2), np.float32(points1), cv2.FM_8POINT)
#get rotation matrix and translation vector
points, R, t, mask = cv2.recoverPose(E, np.float32(points2), np.float32(points1), K, 500)
#caculate the new camera position based on the translation, camPose is the previous camera position
self.cam_xyz.append([self.camPose[0] + t[0], self.camPose[1] + t[1], self.camPose[2] + t[2]])
#calculate the extrinsic matrix
C = np.hstack((R, t))
#calculate the landmark positions
for i in range(len(points2)):
#convert coordinates into a 3x1 array
pts2d = np.asmatrix([points2[i][0], points2[i][1], 1]).T
#calculate camera matrix
P = np.asmatrix(K) * np.asmatrix(C)
#find 3d coordinate
pts3d = np.asmatrix(P).I * pts2d
#add to list of landmarks
self.lm_xyz.append([pts3d[0][0] * self.scale + self.camPose[0],
pts3d[1][0] * self.scale + self.camPose[1],
pts3d[2][0] * self.scale + self.camPose[2]])
#update the previous camera position
self.camPose = [self.camPose[0] + t[0], self.camPose[1] + t[1], self.camPose[2] + t[2]]
When I passed in this video I got this as my output
I can't figure out why it is veering to right when the camera only heads straight in the video. I suspect that I am implementing the cv2.recoverPose method incorrectly but I don't no what else I can do to make it better. I put the full code in a PasteBin in case anyone wants to replicate the program. Any help would be greatly appreciated. Thank you so much!
Shouldn't you calculate the essential matrix E with cv.findEssentialMatrix instead? In this way, you calculated the fundamental matrix F, but to recover the pose, you must pass E = K^T * F * K, w/ K = camera matrix

Gaussian fit fails to return the correct parameters for this PSF

To test my 2D Gaussian fitting code for images of bright objects, I'm running it on a Point Spread Function (PSF) that was constructed and fit by the WISE team. On their website, they list the parameters for the central PSF for each band: the FWHM along the major and minor axes, and position angle (this is the angle from the y-axis). All the information is available here: WISE PSF information, but below is an image of those parameters for the central PSF, and the corresponding image of the PSF for band 3.
So, I have downloaded the corresponding FITS image for the central PSF in band 3 (all the images are available to download via the above link), and tried running my code on it. However, my code does not return the parameters I would expect, and the parameters change depending on if I fit to a subimage (and depending on the size of this), or the whole image, which is kind of worrying.
I am wondering if there's a way to make my Gaussian fit code recover the most accurate parameters -- or maybe another fitting method would be more effective. But I'm mostly concerned at the fact that the output parameters of my Gaussian fit can become so obviously wrong. Below is my code.
from scipy import optimize
import numpy as np
from astropy.io import fits
image = 'wise-w3-psf-wpro-09x09-05x05.fits' #WISE central PSF
stacked_image = fits.getdata(image)
# Center of image (the PSF is centered)
x0 = np.shape(stacked_image)[1]//2
y0 = np.shape(stacked_image)[0]//2
# Normalize image so peak = 1
def normalize(image):
image *= 1/np.max(image)
return image
stacked_image = normalize(stacked_image)
def gaussian_func(xy, x0, y0, sigma_x, sigma_y, amp, theta, offset):
x, y = xy
a = (np.cos(theta))**2/(2*sigma_x**2) + (np.sin(theta))**2/(2*sigma_y**2)
b = -np.sin(2*theta)/(4*sigma_x**2) + np.sin(2*theta)/(4*sigma_y**2)
c = (np.sin(theta))**2/(2*sigma_x**2) + (np.cos(theta))**2/(2*sigma_y**2)
inner = a * (x-x0)**2
inner += 2*b*(x-x0)*(y-y0)
inner += c * (y-y0)**2
return (offset + amp * np.exp(-inner)).ravel()
def Sigma2width(sigma):
return 2 * np.sqrt(2*np.log(2)) * sigma
def generate(data_set):
xvec = np.arange(0, np.shape(data_set)[1], 1)
yvec = np.arange(0, np.shape(data_set)[0], 1)
X, Y = np.meshgrid(xvec, yvec)
return X, Y
# METHOD 1: Fit subimage of PSF to Gaussian
# Guesses
theta_guess = np.deg2rad(96) #I believe that the angle in the Gaussian corresponds to CCW from the x-axis (so use WISE position angle + 90 degrees)
sigma_x = 5
sigma_y = 4
amp = 1 #I know this is true since I normalized it
subimage = stacked_image[y0-50:y0+50, x0-50:x0+50]
offset = np.min(subimage)
guesses = [np.shape(subimage)[1]//2, np.shape(subimage)[0]//2, sigma_x, sigma_y, amp, theta_guess, offset]
xx, yy = generate(subimage)
pred_params, uncert_cov = optimize.curve_fit(gaussian_func, (xx.ravel(), yy.ravel()), subimage.ravel(), p0=guesses)
width_x, width_y = Sigma2width(np.abs(pred_params[2]))*0.275, Sigma2width(np.abs(pred_params[3]))*0.275 #multiply by pixel scale factor (available on website) to get FWHMs in arcseconds
x_0, y_0 = pred_params[0]+(x0-50), pred_params[1]+(y0-50) #add back origin
theta_deg = np.rad2deg(pred_params[5])
pred_params[5] = theta_deg
pred_params[0] = x_0
pred_params[1] = y_0
if theta_deg < 90:
pos_angle = theta_deg+90
elif theta_deg >= 90:
pos_angle = theta_deg-90
print('PREDICTED FWHM x, y in arcsecs:', width_x, width_y)
print('FIT PARAMS [x0, y0, sigma_x, sigma_y, amp, theta, offset]:', pred_params)
print('POSITION ANGLE:', pos_angle)
# Output: PREDICTED FWHM x, y in arcsecs: 6.4917 5.4978
# FIT PARAMS [x0, y0, sigma_x, sigma_y, amp, theta, offset]: [3.195e+02 3.189e+02 1.002e+01 8.489e+00 8.695e-01 8.655e+01 2.613e-02]
# POSITION ANGLE: 176.556
# METHOD 2: Fit whole image to Gaussian
# Guesses
theta_guess = np.deg2rad(96)
sigma_x = 5
sigma_y = 4
amp = 1
offset = np.median(stacked_image)
guesses = [x0, y0, sigma_x, sigma_y, amp, theta_guess, offset]
# Sigmas - manual estimation
ylim, xlim = np.shape(stacked_image)
x, y = np.arange(0, xlim, 1), np.arange(0, ylim, 1)
ypix, xpix = np.where(stacked_image==amp)
y_range = np.take(stacked_image, ypix[0], axis=0)
x_range = np.take(stacked_image, xpix[0], axis=1)
xx, yy = generate(stacked_image)
pred_params, uncert_cov = optimize.curve_fit(gaussian_func, (xx.ravel(), yy.ravel()), stacked_image.ravel(), p0=guesses)
width_x, width_y = Sigma2width(np.abs(pred_params[2]))*0.275, Sigma2width(np.abs(pred_params[3]))*0.275 #in arcsecs
theta = pred_params[5]
print('PREDICTED FWHM x, y in arcsecs:', width_x, width_y)
print('FIT PARAMS [x0, y0, sigma_x, sigma_y, amp, theta, offset]:', pred_params)
# Output:
# PREDICTED FWHM x, y in arcsecs: 7.088 6.106
# FIT PARAMS [x0, y0, sigma_x, sigma_y, amp, theta, offset]: [3.195e+02 3.190e+02 1.095e+01 9.429e+00 8.378e-01 1.521e+00 7.998e-04]
if theta < 90:
pos_angle = 90+np.rad2deg(theta)
elif theta >= 90:
pos_angle = 90-np.rad2deg(theta)
print('POSITION ANGLE:', pos_angle)
# POSITION ANGLE: 177.147
You can see in the (rounded) outputs that the amplitudes my Gaussian fits return aren't even 1, and the other parameters (FWHMs and angles) don't match up with the correct parameters shown in the table either.
If I fit a subimage, it seems that the amplitude gets closer and closer to (but never reaches) 1 the smaller I make the subimage, but then the FWHMs might get too small compared to the real values. Why am I not getting back the correct results, and how can I make the fit as accurate as possible?

Locating the centroid (center of mass) of spherical polygons

I'm trying to work out how best to locate the centroid of an arbitrary shape draped over a unit sphere, with the input being ordered (clockwise or anti-cw) vertices for the shape boundary. The density of vertices is irregular along the boundary, so the arc-lengths between them are not generally equal. Because the shapes may be very large (half a hemisphere) it is generally not possible to simply project the vertices to a plane and use planar methods, as detailed on Wikipedia (sorry I'm not allowed more than 2 hyperlinks as a newcomer). A slightly better approach involves the use of planar geometry manipulated in spherical coordinates, but again, with large polygons this method fails, as nicely illustrated here. On that same page, 'Cffk' highlighted this paper which describes a method for calculating the centroid of spherical triangles. I've tried to implement this method, but without success, and I'm hoping someone can spot the problem?
I have kept the variable definitions similar to those in the paper to make it easier to compare. The input (data) is a list of longitude/latitude coordinates, converted to [x,y,z] coordinates by the code. For each of the triangles I have arbitrarily fixed one point to be the +z-pole, the other two vertices being composed of a pair of neighboring points along the polygon boundary. The code steps along the boundary (starting at an arbitrary point), using each boundary segment of the polygon as a triangle side in turn. A sub-centroid is determined for each of these individual spherical triangles and they are weighted according to triangle area and added to calculate the total polygon centroid. I don't get any errors when running the code, but the total centroids returned are clearly wrong (I have run some very basic shapes where the centroid location is unambiguous). I haven't found any sensible pattern in the location of the centroids returned...so at the moment I'm not sure what is going wrong, either in the math or code (although, the suspicion is the math).
The code below should work copy-paste as is if you would like to try it. If you have matplotlib and numpy installed, it will plot the results (it will ignore plotting if you don't). You just have to put the longitude/latitude data below the code into a text file called example.txt.
from math import *
try:
import matplotlib as mpl
import matplotlib.pyplot
from mpl_toolkits.mplot3d import Axes3D
import numpy
plotting_enabled = True
except ImportError:
plotting_enabled = False
def sph_car(point):
if len(point) == 2:
point.append(1.0)
rlon = radians(float(point[0]))
rlat = radians(float(point[1]))
x = cos(rlat) * cos(rlon) * point[2]
y = cos(rlat) * sin(rlon) * point[2]
z = sin(rlat) * point[2]
return [x, y, z]
def xprod(v1, v2):
x = v1[1] * v2[2] - v1[2] * v2[1]
y = v1[2] * v2[0] - v1[0] * v2[2]
z = v1[0] * v2[1] - v1[1] * v2[0]
return [x, y, z]
def dprod(v1, v2):
dot = 0
for i in range(3):
dot += v1[i] * v2[i]
return dot
def plot(poly_xyz, g_xyz):
fig = mpl.pyplot.figure()
ax = fig.add_subplot(111, projection='3d')
# plot the unit sphere
u = numpy.linspace(0, 2 * numpy.pi, 100)
v = numpy.linspace(-1 * numpy.pi / 2, numpy.pi / 2, 100)
x = numpy.outer(numpy.cos(u), numpy.sin(v))
y = numpy.outer(numpy.sin(u), numpy.sin(v))
z = numpy.outer(numpy.ones(numpy.size(u)), numpy.cos(v))
ax.plot_surface(x, y, z, rstride=4, cstride=4, color='w', linewidth=0,
alpha=0.3)
# plot 3d and flattened polygon
x, y, z = zip(*poly_xyz)
ax.plot(x, y, z)
ax.plot(x, y, zs=0)
# plot the alleged 3d and flattened centroid
x, y, z = g_xyz
ax.scatter(x, y, z, c='r')
ax.scatter(x, y, 0, c='r')
# display
ax.set_xlim3d(-1, 1)
ax.set_ylim3d(-1, 1)
ax.set_zlim3d(0, 1)
mpl.pyplot.show()
lons, lats, v = list(), list(), list()
# put the two-column data at the bottom of the question into a file called
# example.txt in the same directory as this script
with open('example.txt') as f:
for line in f.readlines():
sep = line.split()
lons.append(float(sep[0]))
lats.append(float(sep[1]))
# convert spherical coordinates to cartesian
for lon, lat in zip(lons, lats):
v.append(sph_car([lon, lat, 1.0]))
# z unit vector/pole ('north pole'). This is an arbitrary point selected to act as one
#(fixed) vertex of the summed spherical triangles. The other two vertices of any
#triangle are composed of neighboring vertices from the polygon boundary.
np = [0.0, 0.0, 1.0]
# Gx,Gy,Gz are the cartesian coordinates of the calculated centroid
Gx, Gy, Gz = 0.0, 0.0, 0.0
for i in range(-1, len(v) - 1):
# cycle through the boundary vertices of the polygon, from 0 to n
if all((v[i][0] != v[i+1][0],
v[i][1] != v[i+1][1],
v[i][2] != v[i+1][2])):
# this just ignores redundant points which are common in my larger input files
# A,B,C are the internal angles in the triangle: 'np-v[i]-v[i+1]-np'
A = asin(sqrt((dprod(np, xprod(v[i], v[i+1])))**2
/ ((1 - (dprod(v[i+1], np))**2) * (1 - (dprod(np, v[i]))**2))))
B = asin(sqrt((dprod(v[i], xprod(v[i+1], np)))**2
/ ((1 - (dprod(np , v[i]))**2) * (1 - (dprod(v[i], v[i+1]))**2))))
C = asin(sqrt((dprod(v[i + 1], xprod(np, v[i])))**2
/ ((1 - (dprod(v[i], v[i+1]))**2) * (1 - (dprod(v[i+1], np))**2))))
# A/B/Cbar are the vertex angles, such that if 'O' is the sphere center, Abar
# is the angle (v[i]-O-v[i+1])
Abar = acos(dprod(v[i], v[i+1]))
Bbar = acos(dprod(v[i+1], np))
Cbar = acos(dprod(np, v[i]))
# e is the 'spherical excess', as defined on wikipedia
e = A + B + C - pi
# mag1/2/3 are the magnitudes of vectors np,v[i] and v[i+1].
mag1 = 1.0
mag2 = float(sqrt(v[i][0]**2 + v[i][1]**2 + v[i][2]**2))
mag3 = float(sqrt(v[i+1][0]**2 + v[i+1][1]**2 + v[i+1][2]**2))
# vec1/2/3 are cross products, defined here to simplify the equation below.
vec1 = xprod(np, v[i])
vec2 = xprod(v[i], v[i+1])
vec3 = xprod(v[i+1], np)
# multiplying vec1/2/3 by e and respective internal angles, according to the
#posted paper
for x in range(3):
vec1[x] *= Cbar / (2 * e * mag1 * mag2
* sqrt(1 - (dprod(np, v[i])**2)))
vec2[x] *= Abar / (2 * e * mag2 * mag3
* sqrt(1 - (dprod(v[i], v[i+1])**2)))
vec3[x] *= Bbar / (2 * e * mag3 * mag1
* sqrt(1 - (dprod(v[i+1], np)**2)))
Gx += vec1[0] + vec2[0] + vec3[0]
Gy += vec1[1] + vec2[1] + vec3[1]
Gz += vec1[2] + vec2[2] + vec3[2]
approx_expected_Gxyz = (0.78, -0.56, 0.27)
print('Approximate Expected Gxyz: {0}\n'
' Actual Gxyz: {1}'
''.format(approx_expected_Gxyz, (Gx, Gy, Gz)))
if plotting_enabled:
plot(v, (Gx, Gy, Gz))
Thanks in advance for any suggestions or insight.
EDIT: Here is a figure that shows a projection of the unit sphere with a polygon and the resulting centroid I calculate from the code. Clearly, the centroid is wrong as the polygon is rather small and convex but yet the centroid falls outside its perimeter.
EDIT: Here is a highly-similar set of coordinates to those above, but in the original [lon,lat] format I normally use (which is now converted to [x,y,z] by the updated code).
-39.366295 -1.633460
-47.282630 -0.740433
-53.912136 0.741380
-59.004217 2.759183
-63.489005 5.426812
-68.566001 8.712068
-71.394853 11.659135
-66.629580 15.362600
-67.632276 16.827507
-66.459524 19.069327
-63.819523 21.446736
-61.672712 23.532143
-57.538431 25.947815
-52.519889 28.691766
-48.606227 30.646295
-45.000447 31.089437
-41.549866 32.139873
-36.605156 32.956277
-32.010080 34.156692
-29.730629 33.756566
-26.158767 33.714080
-25.821513 34.179648
-23.614658 36.173719
-20.896869 36.977645
-17.991994 35.600074
-13.375742 32.581447
-9.554027 28.675497
-7.825604 26.535234
-7.825604 26.535234
-9.094304 23.363132
-9.564002 22.527385
-9.713885 22.217165
-9.948596 20.367878
-10.496531 16.486580
-11.151919 12.666850
-12.350144 8.800367
-15.446347 4.993373
-20.366139 1.132118
-24.784805 -0.927448
-31.532135 -1.910227
-39.366295 -1.633460
EDIT: A couple more examples...with 4 vertices defining a perfect square centered at [1,0,0] I get the expected result:
However, from a non-symmetric triangle I get a centroid that is nowhere close...the centroid actually falls on the far side of the sphere (here projected onto the front side as the antipode):
Interestingly, the centroid estimation appears 'stable' in the sense that if I invert the list (go from clockwise to counterclockwise order or vice-versa) the centroid correspondingly inverts exactly.
Anybody finding this, make sure to check Don Hatch's answer which is probably better.
I think this will do it. You should be able to reproduce this result by just copy-pasting the code below.
You will need to have the latitude and longitude data in a file called longitude and latitude.txt. You can copy-paste the original sample data which is included below the code.
If you have mplotlib it will additionally produce the plot below
For non-obvious calculations, I included a link that explains what is going on
In the graph below, the reference vector is very short (r = 1/10) so that the 3d-centroids are easier to see. You can easily remove the scaling to maximize accuracy.
Note to op: I rewrote almost everything so I'm not sure exactly where the original code was not working. However, at least I think it was not taking into consideration the need to handle clockwise / counterclockwise triangle vertices.
Legend:
(black line) reference vector
(small red dots) spherical triangle 3d-centroids
(large red / blue / green dot) 3d-centroid / projected to the surface / projected to the xy plane
(blue / green lines) the spherical polygon and the projection onto the xy plane
from math import *
try:
import matplotlib as mpl
import matplotlib.pyplot
from mpl_toolkits.mplot3d import Axes3D
import numpy
plotting_enabled = True
except ImportError:
plotting_enabled = False
def main():
# get base polygon data based on unit sphere
r = 1.0
polygon = get_cartesian_polygon_data(r)
point_count = len(polygon)
reference = ok_reference_for_polygon(polygon)
# decompose the polygon into triangles and record each area and 3d centroid
areas, subcentroids = list(), list()
for ia, a in enumerate(polygon):
# build an a-b-c point set
ib = (ia + 1) % point_count
b, c = polygon[ib], reference
if points_are_equivalent(a, b, 0.001):
continue # skip nearly identical points
# store the area and 3d centroid
areas.append(area_of_spherical_triangle(r, a, b, c))
tx, ty, tz = zip(a, b, c)
subcentroids.append((sum(tx)/3.0,
sum(ty)/3.0,
sum(tz)/3.0))
# combine all the centroids, weighted by their areas
total_area = sum(areas)
subxs, subys, subzs = zip(*subcentroids)
_3d_centroid = (sum(a*subx for a, subx in zip(areas, subxs))/total_area,
sum(a*suby for a, suby in zip(areas, subys))/total_area,
sum(a*subz for a, subz in zip(areas, subzs))/total_area)
# shift the final centroid to the surface
surface_centroid = scale_v(1.0 / mag(_3d_centroid), _3d_centroid)
plot(polygon, reference, _3d_centroid, surface_centroid, subcentroids)
def get_cartesian_polygon_data(fixed_radius):
cartesians = list()
with open('longitude and latitude.txt') as f:
for line in f.readlines():
spherical_point = [float(v) for v in line.split()]
if len(spherical_point) == 2:
spherical_point.append(fixed_radius)
cartesians.append(degree_spherical_to_cartesian(spherical_point))
return cartesians
def ok_reference_for_polygon(polygon):
point_count = len(polygon)
# fix the average of all vectors to minimize float skew
polyx, polyy, polyz = zip(*polygon)
# /10 is for visualization. Remove it to maximize accuracy
return (sum(polyx)/(point_count*10.0),
sum(polyy)/(point_count*10.0),
sum(polyz)/(point_count*10.0))
def points_are_equivalent(a, b, vague_tolerance):
# vague tolerance is something like a percentage tolerance (1% = 0.01)
(ax, ay, az), (bx, by, bz) = a, b
return all(((ax-bx)/ax < vague_tolerance,
(ay-by)/ay < vague_tolerance,
(az-bz)/az < vague_tolerance))
def degree_spherical_to_cartesian(point):
rad_lon, rad_lat, r = radians(point[0]), radians(point[1]), point[2]
x = r * cos(rad_lat) * cos(rad_lon)
y = r * cos(rad_lat) * sin(rad_lon)
z = r * sin(rad_lat)
return x, y, z
def area_of_spherical_triangle(r, a, b, c):
# points abc
# build an angle set: A(CAB), B(ABC), C(BCA)
# http://math.stackexchange.com/a/66731/25581
A, B, C = surface_points_to_surface_radians(a, b, c)
E = A + B + C - pi # E is called the spherical excess
area = r**2 * E
# add or subtract area based on clockwise-ness of a-b-c
# http://stackoverflow.com/a/10032657/377366
if clockwise_or_counter(a, b, c) == 'counter':
area *= -1.0
return area
def surface_points_to_surface_radians(a, b, c):
"""build an angle set: A(cab), B(abc), C(bca)"""
points = a, b, c
angles = list()
for i, mid in enumerate(points):
start, end = points[(i - 1) % 3], points[(i + 1) % 3]
x_startmid, x_endmid = xprod(start, mid), xprod(end, mid)
ratio = (dprod(x_startmid, x_endmid)
/ ((mag(x_startmid) * mag(x_endmid))))
angles.append(acos(ratio))
return angles
def clockwise_or_counter(a, b, c):
ab = diff_cartesians(b, a)
bc = diff_cartesians(c, b)
x = xprod(ab, bc)
if x < 0:
return 'clockwise'
elif x > 0:
return 'counter'
else:
raise RuntimeError('The reference point is in the polygon.')
def diff_cartesians(positive, negative):
return tuple(p - n for p, n in zip(positive, negative))
def xprod(v1, v2):
x = v1[1] * v2[2] - v1[2] * v2[1]
y = v1[2] * v2[0] - v1[0] * v2[2]
z = v1[0] * v2[1] - v1[1] * v2[0]
return [x, y, z]
def dprod(v1, v2):
dot = 0
for i in range(3):
dot += v1[i] * v2[i]
return dot
def mag(v1):
return sqrt(v1[0]**2 + v1[1]**2 + v1[2]**2)
def scale_v(scalar, v):
return tuple(scalar * vi for vi in v)
def plot(polygon, reference, _3d_centroid, surface_centroid, subcentroids):
fig = mpl.pyplot.figure()
ax = fig.add_subplot(111, projection='3d')
# plot the unit sphere
u = numpy.linspace(0, 2 * numpy.pi, 100)
v = numpy.linspace(-1 * numpy.pi / 2, numpy.pi / 2, 100)
x = numpy.outer(numpy.cos(u), numpy.sin(v))
y = numpy.outer(numpy.sin(u), numpy.sin(v))
z = numpy.outer(numpy.ones(numpy.size(u)), numpy.cos(v))
ax.plot_surface(x, y, z, rstride=4, cstride=4, color='w', linewidth=0,
alpha=0.3)
# plot 3d and flattened polygon
x, y, z = zip(*polygon)
ax.plot(x, y, z, c='b')
ax.plot(x, y, zs=0, c='g')
# plot the 3d centroid
x, y, z = _3d_centroid
ax.scatter(x, y, z, c='r', s=20)
# plot the spherical surface centroid and flattened centroid
x, y, z = surface_centroid
ax.scatter(x, y, z, c='b', s=20)
ax.scatter(x, y, 0, c='g', s=20)
# plot the full set of triangular centroids
x, y, z = zip(*subcentroids)
ax.scatter(x, y, z, c='r', s=4)
# plot the reference vector used to findsub centroids
x, y, z = reference
ax.plot((0, x), (0, y), (0, z), c='k')
ax.scatter(x, y, z, c='k', marker='^')
# display
ax.set_xlim3d(-1, 1)
ax.set_ylim3d(-1, 1)
ax.set_zlim3d(0, 1)
mpl.pyplot.show()
# run it in a function so the main code can appear at the top
main()
Here is the longitude and latitude data you can paste into longitude and latitude.txt
-39.366295 -1.633460
-47.282630 -0.740433
-53.912136 0.741380
-59.004217 2.759183
-63.489005 5.426812
-68.566001 8.712068
-71.394853 11.659135
-66.629580 15.362600
-67.632276 16.827507
-66.459524 19.069327
-63.819523 21.446736
-61.672712 23.532143
-57.538431 25.947815
-52.519889 28.691766
-48.606227 30.646295
-45.000447 31.089437
-41.549866 32.139873
-36.605156 32.956277
-32.010080 34.156692
-29.730629 33.756566
-26.158767 33.714080
-25.821513 34.179648
-23.614658 36.173719
-20.896869 36.977645
-17.991994 35.600074
-13.375742 32.581447
-9.554027 28.675497
-7.825604 26.535234
-7.825604 26.535234
-9.094304 23.363132
-9.564002 22.527385
-9.713885 22.217165
-9.948596 20.367878
-10.496531 16.486580
-11.151919 12.666850
-12.350144 8.800367
-15.446347 4.993373
-20.366139 1.132118
-24.784805 -0.927448
-31.532135 -1.910227
-39.366295 -1.633460
To clarify: the quantity of interest is the projection of the true 3d centroid
(i.e. 3d center-of-mass, i.e. 3d center-of-area) onto the unit sphere.
Since all you care about is the direction from the origin to the 3d centroid,
you don't need to bother with areas at all;
it's easier to just compute the moment (i.e. 3d centroid times area).
The moment of the region to the left of a closed path on the unit sphere
is half the integral of the leftward unit vector as you walk around the path.
This follows from a non-obvious application of Stokes' theorem; see Frank Jones's vector calculus book, chapter 13 Problem 13-12.
In particular, for a spherical polygon, the moment is half the sum of
(a x b) / ||a x b|| * (angle between a and b) for each pair of consecutive vertices a,b.
(That's for the region to the left of the path;
negate it for the region to the right of the path.)
(And if you really did want the 3d centroid, just compute the area and divide the moment by it. Comparing areas might also be useful in choosing which of the two regions to call "the polygon".)
Here's some code; it's really simple:
#!/usr/bin/python
import math
def plus(a,b): return [x+y for x,y in zip(a,b)]
def minus(a,b): return [x-y for x,y in zip(a,b)]
def cross(a,b): return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]]
def dot(a,b): return sum([x*y for x,y in zip(a,b)])
def length(v): return math.sqrt(dot(v,v))
def normalized(v): l = length(v); return [1,0,0] if l==0 else [x/l for x in v]
def addVectorTimesScalar(accumulator, vector, scalar):
for i in xrange(len(accumulator)): accumulator[i] += vector[i] * scalar
def angleBetweenUnitVectors(a,b):
# https://www.plunk.org/~hatch/rightway.html
if dot(a,b) < 0:
return math.pi - 2*math.asin(length(plus(a,b))/2.)
else:
return 2*math.asin(length(minus(a,b))/2.)
def sphericalPolygonMoment(verts):
moment = [0.,0.,0.]
for i in xrange(len(verts)):
a = verts[i]
b = verts[(i+1)%len(verts)]
addVectorTimesScalar(moment, normalized(cross(a,b)),
angleBetweenUnitVectors(a,b) / 2.)
return moment
if __name__ == '__main__':
import sys
def lonlat_degrees_to_xyz(lon_degrees,lat_degrees):
lon = lon_degrees*(math.pi/180)
lat = lat_degrees*(math.pi/180)
coslat = math.cos(lat)
return [coslat*math.cos(lon), coslat*math.sin(lon), math.sin(lat)]
verts = [lonlat_degrees_to_xyz(*[float(v) for v in line.split()])
for line in sys.stdin.readlines()]
#print "verts = "+`verts`
moment = sphericalPolygonMoment(verts)
print "moment = "+`moment`
print "centroid unit direction = "+`normalized(moment)`
For the example polygon, this gives the answer (unit vector):
[-0.7644875430808217, 0.579935445918147, -0.2814847687566214]
This is roughly the same as, but more accurate than, the answer computed by #KobeJohn's code, which uses rough tolerances and planar approximations to the sub-centroids:
[0.7628095787179151, -0.5977153368303585, 0.24669398601094406]
The directions of the two answers are roughly opposite (so I guess KobeJohn's code
decided to take the region to the right of the path in this case).
I think a good approximation would be to compute the center of mass using weighted cartesian coordinates and projecting the result onto the sphere (supposing the origin of coordinates is (0, 0, 0)^T).
Let be (p[0], p[1], ... p[n-1]) the n points of the polygon. The approximative (cartesian) centroid can be computed by:
c = 1 / w * (sum of w[i] * p[i])
whereas w is the sum of all weights and whereas p[i] is a polygon point and w[i] is a weight for that point, e.g.
w[i] = |p[i] - p[(i - 1 + n) % n]| / 2 + |p[i] - p[(i + 1) % n]| / 2
whereas |x| is the length of a vector x.
I.e. a point is weighted with half the length to the previous and half the length to the next polygon point.
This centroid c can now projected onto the sphere by:
c' = r * c / |c|
whereas r is the radius of the sphere.
To consider orientation of polygon (ccw, cw) the result may be
c' = - r * c / |c|.
Sorry I (as a newly registered user) had to write a new post instead of just voting/commenting on the above answer by Don Hatch. Don's answer, I think, is the best and most elegant. It is mathematically rigorous in computing the center of mass (first moment of mass) in a simple way when applying to the spherical polygon.
Kobe John's answer is a good approximation but only satisfactory for smaller areas. I also noticed a few glitches in the code. Firstly, the reference point should be projected to the spherical surface to compute the actual spherical area. Secondly, function points_are_equivalent() might need to be refined to avoid divided-by-zero.
The approximation error in Kobe's method lies in the calculation of the centroid of spherical triangles. The sub-centroid is NOT the center of mass of the spherical triangle but the planar one. This is not an issue if one is to determine that single triangle (sign may flip, see below). It is also not an issue if triangles are small (e.g. a dense triangulation of the polygon).
A few simple tests could illustrate the approximation error. For example if we use just four points:
10 -20
10 20
-10 20
-10 -20
The exact answer is (1,0,0) and both methods are good. But if you throw in a few more points along one edge (e.g. add {10,-15},{10,-10}... to the first edge), you'll see the results from Kobe's method start to shift. Further more, if you increase the longitude from [10,-10] to [100,-100], you'll see Kobe's result flips the direction. A possible improvement might be to add another level(s) for sub-centroid calculation (basically refine/reduce sizes of triangles).
For our application, the spherical area boundary is composed of multiple arcs and thus not polygon (i.e. the arc is not part of great circle). But this will just be a little more work to find the n-vector in the curve integration.
EDIT: Replacing the subcentroid calculation with the one given in Brock's paper should fix Kobe's method. But I did not try though.

Categories

Resources