Commit 2c63b0e8 authored by jp6g18's avatar jp6g18

Merge branch 'master' into 'Omar'

# Conflicts:
#   AmpScan/ampVis.py
#   GUIs/AmpScanGUI.py
parents 5a608740 13398e31
Pipeline #884 failed with stage
in 25 seconds
sample_job:
script: python tests/sample_test.py
#doctests and unitests:
# script: pytest --doctest-modules -v --ignore=GUIs
# Temporarily added back old testing
unittests:
script: python -m unittest discover tests -v
core doctests:
script: python -m doctest -v AmpScan/core.py
registration doctests:
script: python -m doctest -v AmpScan/registration.py
align doctests:
script: python -m doctest -v AmpScan/align.py
......@@ -10,8 +10,13 @@ import vtk
import math
from scipy import spatial
from scipy.optimize import minimize
from .core import AmpObject
from .ampVis import vtkRenWin
from AmpScan.core import AmpObject
from AmpScan.ampVis import vtkRenWin
# For doc examples
import os
staticfh = os.getcwd() + "\\tests\\stl_file.stl"
movingfh = os.getcwd() + "\\tests\\stl_file_2.stl"
class align(object):
......@@ -41,9 +46,9 @@ class align(object):
Examples
--------
>>> static = AmpScan.AmpObject(staticfh)
>>> moving = AmpScan.AmpObject(movingfh)
>>> al = AmpScan.align(moving, static).m
>>> static = AmpObject(staticfh)
>>> moving = AmpObject(movingfh)
>>> al = align(moving, static).m
"""
......@@ -131,7 +136,7 @@ class align(object):
R = Rs[:, :, -1]
#Simpl
[U, s, V] = np.linalg.svd(R)
R = np.dot(U, V.T)
R = np.dot(U, V)
self.tForm = np.r_[np.c_[R, np.zeros(3)], np.append(Ts[:, -1], 1)[:, None].T]
self.R = R
self.T = Ts[:, -1]
......@@ -176,9 +181,9 @@ class align(object):
Examples
--------
>>> static = AmpScan.AmpObject(staticfh)
>>> moving = AmpScan.AmpObject(movingfh)
>>> al = AmpScan.align(moving, static, method='linPoint2Plane').m
>>> static = AmpObject(staticfh)
>>> moving = AmpObject(movingfh)
>>> al = align(moving, static, method='linPoint2Plane').m
"""
cn = np.c_[np.cross(mv, sn), sn]
......@@ -229,9 +234,9 @@ class align(object):
Examples
--------
>>> static = AmpScan.AmpObject(staticfh)
>>> moving = AmpScan.AmpObject(movingfh)
>>> al = AmpScan.align(moving, static, method='linPoint2Point').m
>>> static = AmpObject(staticfh)
>>> moving = AmpObject(movingfh)
>>> al = align(moving, static, method='linPoint2Point').m
"""
mCent = mv - mv.mean(axis=0)
......@@ -271,9 +276,9 @@ class align(object):
Examples
--------
>>> static = AmpScan.AmpObject(staticfh)
>>> moving = AmpScan.AmpObject(movingfh)
>>> al = AmpScan.align(moving, static, method='optPoint2Point', opt='SLSQP').m
>>> static = AmpObject(staticfh)
>>> moving = AmpObject(movingfh)
>>> al = align(moving, static, method='optPoint2Point', opt='SLSQP').m
"""
X = np.zeros(6)
......
......@@ -374,7 +374,7 @@ class visMixin(object):
def genIm(self, size=[512, 512], views=[[0, -1, 0]],
background=[1.0, 1.0, 1.0], projection=True,
shading=True, mag=10, out='im', fh='test.tiff',
zoom=1.0, az = 0, el=0,crop=False):
zoom=1.0, az = 0, el=0,crop=False, cam=None):
r"""
Creates a temporary off screen vtkRenWin which is then either returned
as a numpy array or saved as a .png file
......@@ -411,6 +411,7 @@ class visMixin(object):
self.addActor()
# Generate a renderer window
win = vtkRenWin()
win.OffScreenRenderingOn()
# Set the number of viewports
win.setnumViewports(len(views))
# Set the background colour
......@@ -427,7 +428,8 @@ class visMixin(object):
# win.setProjection(projection, viewport=i)
win.renderActors([self.actor,], zoom=zoom)
win.rens[0].GetActiveCamera().Azimuth(az)
win.rens[0].GetActiveCamera().Elevation(el)
if cam is not None:
win.rens[0].SetActiveCamera(cam)
win.Render()
if out == 'im':
im = win.getImage()
......@@ -438,7 +440,7 @@ class visMixin(object):
mask = np.all(im == 1, axis=2)
mask = ~np.all(mask, axis=0)
im = im[:, mask, :]
return im
return im, win
elif out == 'fh':
win.getScreenshot(fh)
return
......
......@@ -6,11 +6,17 @@ Copyright: Joshua Steer 2018, Joshua.Steer@soton.ac.uk
"""
import numpy as np
import os
import struct
from .trim import trimMixin
from .smooth import smoothMixin
from .analyse import analyseMixin
from .ampVis import visMixin
from AmpScan.trim import trimMixin
from AmpScan.smooth import smoothMixin
from AmpScan.analyse import analyseMixin
from AmpScan.ampVis import visMixin
# The file path used in doc examples
filename = os.getcwd()+"\\tests\\stl_file.stl"
class AmpObject(trimMixin, smoothMixin, analyseMixin, visMixin):
r"""
......@@ -36,8 +42,7 @@ class AmpObject(trimMixin, smoothMixin, analyseMixin, visMixin):
Examples
-------
>>> fh = 'test.stl'
>>> amp = AmpScan.AmpObject(fh)
>>> amp = AmpObject(filename)
"""
......@@ -149,13 +154,12 @@ class AmpObject(trimMixin, smoothMixin, analyseMixin, visMixin):
Examples
--------
>>> fh = 'test.stl'
>>> amp = AmpObject(fh, unify=False)
>>> amp = AmpObject(filename, unify=False)
>>> amp.vert.shape
(600, 3)
(44832, 3)
>>> amp.unifyVert()
>>> amp.vert.shape
(125, 3)
(7530, 3)
"""
# Requires numpy 1.13
......@@ -314,7 +318,16 @@ class AmpObject(trimMixin, smoothMixin, analyseMixin, visMixin):
Translation in [x, y, z]
"""
self.vert[:] += trans
# Check that trans is array like
if isinstance(trans, (list, np.ndarray, tuple)):
# Check that trans has exactly 3 dimensions
if len(trans) == 3:
self.vert[:] += trans
else:
raise ValueError("Translation has incorrect dimensions. Expected 3 but found: " + str(len(trans)))
else:
raise TypeError("Translation is not array_like: " + trans)
def centre(self):
r"""
......@@ -337,10 +350,15 @@ class AmpObject(trimMixin, smoothMixin, analyseMixin, visMixin):
Examples
--------
>>> amp = AmpObject('test.stl')
>>> amp = AmpObject(filename)
>>> ang = [np.pi/2, -np.pi/4, np.pi/3]
>>> amp.rotateAng(ang, ang='rad')
"""
# Check that ang is valid
if ang not in ('rad', 'deg'):
raise ValueError("Ang expected 'rad' or 'deg' but {} was found".format(ang))
if isinstance(rot, (tuple, list, np.ndarray)):
R = self.rotMatrix(rot, ang)
self.rotate(R, norms)
......@@ -359,6 +377,18 @@ class AmpObject(trimMixin, smoothMixin, analyseMixin, visMixin):
norms: boolean, default True
"""
if isinstance(R, (list, tuple)):
# Make R a np array if its a list or tuple
R = np.array(R, np.float)
elif not isinstance(R, np.ndarray):
# If
raise TypeError("Expected R to be array-like but found: " + str(type(R)))
if len(R) != 3 or len(R[0]) != 3:
# Incorrect dimensions
if isinstance(R, np.ndarray):
raise ValueError("Expected 3x3 array, but found: {}".format(R.shape))
else:
raise ValueError("Expected 3x3 array, but found: 3x"+str(len(R)))
self.vert[:, :] = np.dot(self.vert, R.T)
if norms is True:
self.norm[:, :] = np.dot(self.norm, R.T)
......@@ -380,9 +410,15 @@ class AmpObject(trimMixin, smoothMixin, analyseMixin, visMixin):
"""
if R is not None:
self.rotate(R, True)
if isinstance(R, (tuple, list, np.ndarray)):
self.rotate(R, True)
else:
raise TypeError("Expecting array-like rotation, but found: "+type(R))
if T is not None:
self.translate(T)
if isinstance(T, (tuple, list, np.ndarray)):
self.translate(T)
else:
raise TypeError("Expecting array-like translation, but found: "+type(T))
@staticmethod
......@@ -396,7 +432,7 @@ class AmpObject(trimMixin, smoothMixin, analyseMixin, visMixin):
rot: array_like
Rotation around [x, y, z]
ang: str, default 'rad'
Specift if the Euler angles are in degrees or radians
Specify if the Euler angles are in degrees or radians
Returns
-------
......@@ -404,8 +440,20 @@ class AmpObject(trimMixin, smoothMixin, analyseMixin, visMixin):
The calculated 3x3 rotation matrix
"""
# Check that rot is valid
if not isinstance(rot, (tuple, list, np.ndarray)):
raise TypeError("Expecting array-like rotation, but found: "+type(rot))
elif len(rot) != 3:
raise ValueError("Expecting 3 arguments but found: {}".format(len(rot)))
# Check that ang is valid
if ang not in ('rad', 'deg'):
raise ValueError("Ang expected 'rad' or 'deg' but {} was found".format(ang))
if ang == 'deg':
rot = np.deg2rad(rot)
[angx, angy, angz] = rot
Rx = np.array([[1, 0, 0],
[0, np.cos(angx), -np.sin(angx)],
......@@ -429,8 +477,14 @@ class AmpObject(trimMixin, smoothMixin, analyseMixin, visMixin):
The axis in which to flip the mesh
"""
self.vert[:, axis] *= -1.0
# Switch face order to normals face same direction
self.faces[:, [1, 2]] = self.faces[:, [2, 1]]
self.calcNorm()
self.calcVNorm()
if isinstance(axis, int):
if 0 <= axis < 3: # Check axis is between 0-2
self.vert[:, axis] *= -1.0
# Switch face order to normals face same direction
self.faces[:, [1, 2]] = self.faces[:, [2, 1]]
self.calcNorm()
self.calcVNorm()
else:
raise ValueError("Expected axis to be within range 0-2 but found: {}".format(axis))
else:
raise TypeError("Expected axis to be int, but found: {}".format(type(axis)))
......@@ -6,9 +6,14 @@ Copyright: Joshua Steer 2018, Joshua.Steer@soton.ac.uk
import numpy as np
import copy
from scipy import spatial
from .core import AmpObject
from AmpScan.core import AmpObject
import matplotlib.pyplot as plt
# For the doc examples
import os
basefh = os.getcwd()+"\\tests\\stl_file.stl"
targfh = os.getcwd()+"\\tests\\stl_file_2.stl"
class registration(object):
r"""
Registration methods between two AmpObject meshes. This function morphs the baseline
......@@ -36,9 +41,10 @@ class registration(object):
Examples
--------
>>> baseline = AmpScan.AmpObject(basefh)
>>> target = AmpScan.AmpObject(targfh)
>>> reg = AmpScan.registration(steps=10, neigh=10, smooth=1).reg
>>> from AmpScan.core import AmpObject
>>> baseline = AmpObject(basefh)
>>> target = AmpObject(targfh)
>>> reg = registration(baseline, target, steps=10, neigh=10, smooth=1).reg
"""
def __init__(self, baseline, target, method='point2plane', *args, **kwargs):
......@@ -86,53 +92,52 @@ class registration(object):
[self.b.vert, self.b.faces, self.b.values]))
regData = copy.deepcopy(bData)
self.reg = AmpObject(regData, stype='reg')
self.disp = AmpObject({'vert': np.zeros(self.reg.vert.shape),
'faces': self.reg.faces,
'values':self.reg.values})
if scale is not None:
tmin = self.t.vert.min(axis=0)[2]
rmin = self.reg.vert.min(axis=0)[2]
SF = ((tmin-scale)/(rmin-scale)) - 1
logic = self.reg.vert[:, 2] < scale
d = (self.reg.vert[logic, 2] - scale) * SF
self.reg.vert[logic, 2] += d
self.disp.vert[logic, 2] += d
self.reg.vert = self.b.vert + self.disp.vert
normals = np.cross(self.t.vert[self.t.faces[:,1]] -
self.t.vert[self.t.faces[:,0]],
self.t.vert[self.t.faces[:,2]] -
self.t.vert[self.t.faces[:,0]])
mag = (normals**2).sum(axis=1)
if subset is None:
rVert = self.reg.vert
else:
rVert = self.reg.vert[subset]
for step in np.arange(steps, 0, -1, dtype=float):
# Index of 10 centroids nearest to each baseline vertex
ind = tTree.query(rVert, neigh)[1]
# D = np.zeros(self.reg.vert.shape)
ind = tTree.query(self.reg.vert, neigh)[1]
# Define normals for faces of nearest faces
norms = normals[ind]
# Get a point on each face
fPoints = self.t.vert[self.t.faces[ind, 0]]
# Calculate dot product between point on face and normals
d = np.einsum('ijk, ijk->ij', norms, fPoints)
t = (d - np.einsum('ijk, ik->ij', norms, rVert))/mag[ind]
t = (d - np.einsum('ijk, ik->ij', norms, self.reg.vert))/mag[ind]
# Calculate the vector from old point to new point
G = rVert[:, None, :] + np.einsum('ijk, ij->ijk', norms, t)
G = self.reg.vert[:, None, :] + np.einsum('ijk, ij->ijk', norms, t)
# Ensure new points lie inside points otherwise set to 99999
# Find smallest distance from old to new point
if inside is False:
G = G - rVert[:, None, :]
G = G - self.reg.vert[:, None, :]
GMag = np.sqrt(np.einsum('ijk, ijk->ij', G, G))
GInd = GMag.argmin(axis=1)
else:
G, GInd = self.__calcBarycentric(rVert, G, ind)
G, GInd = self.__calcBarycentric(self.reg.vert, G, ind)
# Define vector from baseline point to intersect point
D = G[np.arange(len(G)), GInd, :]
rVert += D/step
# rVert += D/step
self.disp.vert += D/step
if smooth > 0 and step > 1:
# v = self.reg.vert[~subset]
self.reg.lp_smooth(smooth, brim = fixBrim)
# self.reg.vert[~subset] = v
self.disp.lp_smooth(smooth, brim = fixBrim)
self.reg.vert = self.b.vert + self.disp.vert
else:
self.reg.vert = self.b.vert + self.disp.vert
self.reg.calcNorm()
self.reg.calcStruct()
self.reg.values[:] = self.calcError(error)
......
......@@ -52,7 +52,7 @@ class smoothMixin(object):
def smoothValues(self, n=1):
"""
Function to apply a simple laplacian smooth to the values array.
Identical to the vertex smoothing expect it applies the smoothing
Identical to the vertex smoothing except it applies the smoothing
to the values
Parameters
......
......@@ -9,6 +9,7 @@ import os
import numpy as np
from .core import AmpObject
from .registration import registration
import os
class pca(object):
......@@ -18,20 +19,20 @@ class pca(object):
Examples
--------
>>> import os
>>> p = pca()
>>> p.importFolder('/path/')
>>> p.baseline('dir/baselinefh.stl')
>>> p.register(save = '/regpath/')
>>> p.importFolder(os.getcwd()+"\\tests\\pca_tests")
>>> p.setBaseline(os.getcwd()+"\\tests\\stl_file_3.stl")
>>> p.register(save=os.getcwd()+"\\tests\\pca_tests\\")
>>> p.pca()
>>> sfs = [0, 0.1, -0.5 ... 0]
>>> sfs = [1, 2]
>>> newS = p.newShape(sfs)
"""
def __init__(self):
self.shapes = []
def setBaseline(self, baseline):
r"""
Function to set the baseline mesh used for registration of the
......@@ -44,8 +45,7 @@ class pca(object):
"""
self.baseline = AmpObject(baseline, 'limb')
def importFolder(self, path, unify=True):
r"""
Function to import multiple stl files from folder into the pca object
......@@ -60,10 +60,10 @@ class pca(object):
"""
self.fnames = [f for f in os.listdir(path) if f.endswith('.stl')]
self.shapes = [AmpObject(path + f, 'limb', unify=unify) for f in self.fnames]
self.shapes = [AmpObject(os.path.join(path, f), 'limb', unify=unify) for f in self.fnames]
for s in self.shapes:
s.lp_smooth(3, brim=True)
def sliceFiles(self, height):
r"""
Function to run a planar trim on all the training data for the PCA
......@@ -77,7 +77,7 @@ class pca(object):
"""
for s in self.shapes:
s.planarTrim(height)
def register(self, scale=None, save=None, baseline=True):
r"""
Register all the AmpObject training data to the baseline AmpObject
......@@ -100,12 +100,11 @@ class pca(object):
self.registered.append(r)
if save is not None:
for f, r in zip(self.fnames, self.registered):
r.save(save + f)
r.save(os.path.join(save, f))
self.X = np.array([r.vert.flatten() for r in self.registered]).T
if baseline is True:
self.X = np.c_[self.X, self.baseline.vert.flatten()]
def pca(self):
r"""
Function to run mean centered pca using a singular value decomposition
......@@ -117,8 +116,8 @@ class pca(object):
(self.pca_U, self.pca_S, self.pca_V) = np.linalg.svd(X_meanC, full_matrices=False)
self.pc_weights = np.dot(np.diag(self.pca_S), self.pca_V)
self.pc_stdevs = np.std(self.pc_weights, axis=1)
def newShape(self, sfs, scale = 'eigs'):
def newShape(self, sfs, scale='eigs'):
r"""
Function to calculate a new shape based upon the eigenvalues
or stdevs
......@@ -133,11 +132,15 @@ class pca(object):
to standard deviations about the mean
"""
try: len(sfs) == len(self.pc_stdevs)
except: ValueError('sfs must be of the same length as the number of '
'principal components')
if not isinstance(sfs, (list, tuple, np.ndarray)):
raise TypeError('sfs is invalid type (expected array-like, found: {}'.format(type(sfs)))
if len(sfs) != len(self.pc_stdevs):
raise ValueError('sfs must be of the same length as the number of '
'principal components (expected {} but found {})'.format(len(self.pc_stdevs), len(sfs)))
if scale == 'eigs':
sf = (self.pca_U * sfs).sum(axis=1)
elif scale == 'std':
sf = (self.pca_U * self.pc_stdevs * sfs).sum(axis=1)
else:
raise ValueError("Invalid scale (expected 'eigs' or 'std' but found{}".format(scale))
return self.pca_mean + sf
......@@ -5,6 +5,12 @@ Copyright: Joshua Steer 2018, Joshua.Steer@soton.ac.uk
"""
import numpy as np
from numbers import Number
import os
# Used by doc tests
filename = os.getcwd() + "\\tests\\stl_file.stl"
class trimMixin(object):
r"""
......@@ -26,11 +32,13 @@ class trimMixin(object):
Examples
--------
>>> amp = AmpObject(fh)
>>> from AmpScan import AmpObject
>>> amp = AmpObject(filename)
>>> amp.planarTrim(100, 2)
"""
if isinstance(height, float) and isinstance(plane, int):
if isinstance(height, Number) and isinstance(plane, int):
# planar values for each vert on face
fv = self.vert[self.faces, plane]
# Number points on each face are above cut plane
......@@ -49,6 +57,7 @@ class trimMixin(object):
self.faces = self.faces[fvlogic != 3, :]
self.faces = vInd[self.faces]
self.vert = self.vert[~delv, :]
self.values = self.values[~delv]
self.calcStruct()
else:
raise TypeError("height arg must be a float and plane arg must be an int")
\ No newline at end of file
raise TypeError("height arg must be a float")
To contribute to the open source AmpScan package
\ No newline at end of file
......@@ -10,9 +10,9 @@ from PyQt5.QtGui import (QColor, QFontMetrics, QImage, QPainter, QIcon,
QOpenGLVersionProfile)
from PyQt5.QtWidgets import (QAction, QApplication, QGridLayout, QHBoxLayout,
QMainWindow, QMessageBox, QComboBox, QButtonGroup,
QOpenGLWidget, QFileDialog,QLabel,QPushButton,
QOpenGLWidget, QFileDialog, QLabel, QPushButton,
QSlider, QWidget, QTableWidget, QTableWidgetItem,
QAbstractButton, QCheckBox)
QAbstractButton, QCheckBox, QErrorMessage)
class AmpScanGUI(QMainWindow):
......@@ -113,16 +113,19 @@ class AmpScanGUI(QMainWindow):
Numpy style docstring.
"""
self.alCont = AlignControls(self.filesDrop, self)
self.alCont.show()
self.alCont.centre.clicked.connect(self.centreMesh)
self.alCont.icp.clicked.connect(self.runICP)
self.alCont.xrotButton.buttonClicked[QAbstractButton].connect(self.rotatex)
self.alCont.yrotButton.buttonClicked[QAbstractButton].connect(self.rotatey)
self.alCont.zrotButton.buttonClicked[QAbstractButton].connect(self.rotatez)
self.alCont.xtraButton.buttonClicked[QAbstractButton].connect(self.transx)
self.alCont.ytraButton.buttonClicked[QAbstractButton].connect(self.transy)
self.alCont.ztraButton.buttonClicked[QAbstractButton].connect(self.transz)
if self.objectsReady(1):
self.alCont = AlignControls(self.filesDrop, self)
self.alCont.show()
self.alCont.centre.clicked.connect(self.centreMesh)
self.alCont.icp.clicked.connect(self.runICP)
self.alCont.xrotButton.buttonClicked[QAbstractButton].connect(self.rotatex)
self.alCont.yrotButton.buttonClicked[QAbstractButton].connect(self.rotatey)
self.alCont.zrotButton.buttonClicked[QAbstractButton].connect(self.rotatez)
self.alCont.xtraButton.buttonClicked[QAbstractButton].connect(self.transx)
self.alCont.ytraButton.buttonClicked[QAbstractButton].connect(self.transy)
self.alCont.ztraButton.buttonClicked[QAbstractButton].connect(self.transz)
else:
show_message("Must be at least 1 object loaded to run align")
def Point_Pick(self):
"""
......@@ -155,7 +158,6 @@ class AmpScanGUI(QMainWindow):
self.pnt = None
vtkRenWin.delMarker(self.renWin)
def rotatex(self, button):
moving = str(self.alCont.moving.currentText())
ang = float(button.text())
......@@ -216,59 +218,64 @@ class AmpScanGUI(QMainWindow):
self.renWin.Render()
def runICP(self):
static = str(self.alCont.static.currentText())
moving = str(self.alCont.moving.currentText())
al = align(self.files[moving], self.files[static],
maxiter=10, method='linPoint2Plane').m
al.tform = vtk.vtkTransform()
al.tform.PostMultiply()
al.addActor()