-
Notifications
You must be signed in to change notification settings - Fork 0
/
endoscope.py
550 lines (468 loc) · 19.2 KB
/
endoscope.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
import os
import unittest
import vtk, qt, ctk, slicer
from slicer.ScriptedLoadableModule import *
import logging
#
# Endoscopy
#
class Endoscopy(ScriptedLoadableModule):
"""Uses ScriptedLoadableModule base class, available at:
https://github.com/Slicer/Slicer/blob/master/Base/Python/slicer/ScriptedLoadableModule.py
"""
def __init__(self, parent):
ScriptedLoadableModule.__init__(self, parent)
self.parent.title = "Endoscopy"
self.parent.categories = ["Endoscopy"]
self.parent.dependencies = []
self.parent.contributors = ["Steve Pieper (Isomics)"]
self.parent.helpText = """
Create a path model as a spline interpolation of a set of fiducial points.
Pick the Camera to be modified by the path and the Fiducial List defining the control points.
Clicking "Create path" will make a path model and enable the flythrough panel.
You can manually scroll through the path with the Frame slider. The Play/Pause button toggles animated flythrough.
The Frame Skip slider speeds up the animation by skipping points on the path.
The Frame Delay slider slows down the animation by adding more time between frames.
The View Angle provides is used to approximate the optics of an endoscopy system.
"""
self.parent.helpText += self.getDefaultModuleDocumentationLink()
self.parent.acknowledgementText = """
This work is supported by PAR-07-249: R01CA131718 NA-MIC Virtual Colonoscopy
(See <a>http://www.na-mic.org/Wiki/index.php/NA-MIC_NCBC_Collaboration:NA-MIC_virtual_colonoscopy</a>)
NA-MIC, NAC, BIRN, NCIGT, and the Slicer Community. See http://www.slicer.org for details. Module implemented by Steve Pieper.
"""
#
# qSlicerPythonModuleExampleWidget
#
class EndoscopyWidget(ScriptedLoadableModuleWidget):
"""Uses ScriptedLoadableModuleWidget base class, available at:
https://github.com/Slicer/Slicer/blob/master/Base/Python/slicer/ScriptedLoadableModule.py
"""
def __init__(self, parent=None):
ScriptedLoadableModuleWidget.__init__(self, parent)
self.cameraNode = None
self.cameraNodeObserverTag = None
self.cameraObserverTag= None
# Flythough variables
self.transform = None
self.path = None
self.camera = None
self.skip = 0
self.timer = qt.QTimer()
self.timer.setInterval(20)
self.timer.connect('timeout()', self.flyToNext)
def setup(self):
ScriptedLoadableModuleWidget.setup(self)
# Path collapsible button
pathCollapsibleButton = ctk.ctkCollapsibleButton()
pathCollapsibleButton.text = "Path"
self.layout.addWidget(pathCollapsibleButton)
# Layout within the path collapsible button
pathFormLayout = qt.QFormLayout(pathCollapsibleButton)
# Camera node selector
cameraNodeSelector = slicer.qMRMLNodeComboBox()
cameraNodeSelector.objectName = 'cameraNodeSelector'
cameraNodeSelector.toolTip = "Select a camera that will fly along this path."
cameraNodeSelector.nodeTypes = ['vtkMRMLCameraNode']
cameraNodeSelector.noneEnabled = False
cameraNodeSelector.addEnabled = False
cameraNodeSelector.removeEnabled = False
cameraNodeSelector.connect('currentNodeChanged(bool)', self.enableOrDisableCreateButton)
cameraNodeSelector.connect('currentNodeChanged(vtkMRMLNode*)', self.setCameraNode)
pathFormLayout.addRow("Camera:", cameraNodeSelector)
self.parent.connect('mrmlSceneChanged(vtkMRMLScene*)',
cameraNodeSelector, 'setMRMLScene(vtkMRMLScene*)')
# Input fiducials node selector
inputFiducialsNodeSelector = slicer.qMRMLNodeComboBox()
inputFiducialsNodeSelector.objectName = 'inputFiducialsNodeSelector'
inputFiducialsNodeSelector.toolTip = "Select a fiducial list to define control points for the path."
inputFiducialsNodeSelector.nodeTypes = ['vtkMRMLMarkupsFiducialNode', 'vtkMRMLAnnotationHierarchyNode', 'vtkMRMLFiducialListNode']
inputFiducialsNodeSelector.noneEnabled = False
inputFiducialsNodeSelector.addEnabled = False
inputFiducialsNodeSelector.removeEnabled = False
inputFiducialsNodeSelector.connect('currentNodeChanged(bool)', self.enableOrDisableCreateButton)
pathFormLayout.addRow("Input Fiducials:", inputFiducialsNodeSelector)
self.parent.connect('mrmlSceneChanged(vtkMRMLScene*)',
inputFiducialsNodeSelector, 'setMRMLScene(vtkMRMLScene*)')
# CreatePath button
createPathButton = qt.QPushButton("Create path")
createPathButton.toolTip = "Create the path."
createPathButton.enabled = False
pathFormLayout.addRow(createPathButton)
createPathButton.connect('clicked()', self.onCreatePathButtonClicked)
# Flythrough collapsible button
flythroughCollapsibleButton = ctk.ctkCollapsibleButton()
flythroughCollapsibleButton.text = "Flythrough"
flythroughCollapsibleButton.enabled = False
self.layout.addWidget(flythroughCollapsibleButton)
# Layout within the Flythrough collapsible button
flythroughFormLayout = qt.QFormLayout(flythroughCollapsibleButton)
# Frame slider
frameSlider = ctk.ctkSliderWidget()
frameSlider.connect('valueChanged(double)', self.frameSliderValueChanged)
frameSlider.decimals = 0
flythroughFormLayout.addRow("Frame:", frameSlider)
# Frame skip slider
frameSkipSlider = ctk.ctkSliderWidget()
frameSkipSlider.connect('valueChanged(double)', self.frameSkipSliderValueChanged)
frameSkipSlider.decimals = 0
frameSkipSlider.minimum = 0
frameSkipSlider.maximum = 50
flythroughFormLayout.addRow("Frame skip:", frameSkipSlider)
# Frame delay slider
frameDelaySlider = ctk.ctkSliderWidget()
frameDelaySlider.connect('valueChanged(double)', self.frameDelaySliderValueChanged)
frameDelaySlider.decimals = 0
frameDelaySlider.minimum = 5
frameDelaySlider.maximum = 100
frameDelaySlider.suffix = " ms"
frameDelaySlider.value = 20
flythroughFormLayout.addRow("Frame delay:", frameDelaySlider)
# View angle slider
viewAngleSlider = ctk.ctkSliderWidget()
viewAngleSlider.connect('valueChanged(double)', self.viewAngleSliderValueChanged)
viewAngleSlider.decimals = 0
viewAngleSlider.minimum = 30
viewAngleSlider.maximum = 180
flythroughFormLayout.addRow("View Angle:", viewAngleSlider)
# Play button
playButton = qt.QPushButton("Play")
playButton.toolTip = "Fly through path."
playButton.checkable = True
flythroughFormLayout.addRow(playButton)
playButton.connect('toggled(bool)', self.onPlayButtonToggled)
# Add vertical spacer
self.layout.addStretch(1)
# Set local var as instance attribute
self.cameraNodeSelector = cameraNodeSelector
self.inputFiducialsNodeSelector = inputFiducialsNodeSelector
self.createPathButton = createPathButton
self.flythroughCollapsibleButton = flythroughCollapsibleButton
self.frameSlider = frameSlider
self.viewAngleSlider = viewAngleSlider
self.playButton = playButton
cameraNodeSelector.setMRMLScene(slicer.mrmlScene)
inputFiducialsNodeSelector.setMRMLScene(slicer.mrmlScene)
def setCameraNode(self, newCameraNode):
"""Allow to set the current camera node.
Connected to signal 'currentNodeChanged()' emitted by camera node selector."""
# Remove previous observer
if self.cameraNode and self.cameraNodeObserverTag:
self.cameraNode.RemoveObserver(self.cameraNodeObserverTag)
if self.camera and self.cameraObserverTag:
self.camera.RemoveObserver(self.cameraObserverTag)
newCamera = None
if newCameraNode:
newCamera = newCameraNode.GetCamera()
# Add CameraNode ModifiedEvent observer
self.cameraNodeObserverTag = newCameraNode.AddObserver(vtk.vtkCommand.ModifiedEvent, self.onCameraNodeModified)
# Add Camera ModifiedEvent observer
self.cameraObserverTag = newCamera.AddObserver(vtk.vtkCommand.ModifiedEvent, self.onCameraNodeModified)
self.cameraNode = newCameraNode
self.camera = newCamera
# Update UI
self.updateWidgetFromMRML()
def updateWidgetFromMRML(self):
if self.camera:
self.viewAngleSlider.value = self.camera.GetViewAngle()
if self.cameraNode:
pass
def onCameraModified(self, observer, eventid):
self.updateWidgetFromMRML()
def onCameraNodeModified(self, observer, eventid):
self.updateWidgetFromMRML()
def enableOrDisableCreateButton(self):
"""Connected to both the fiducial and camera node selector. It allows to
enable or disable the 'create path' button."""
self.createPathButton.enabled = self.cameraNodeSelector.currentNode() is not None and self.inputFiducialsNodeSelector.currentNode() is not None
def onCreatePathButtonClicked(self):
"""Connected to 'create path' button. It allows to:
- compute the path
- create the associated model"""
fiducialsNode = self.inputFiducialsNodeSelector.currentNode();
print "Calculating Path..."
result = EndoscopyComputePath(fiducialsNode)
print "-> Computed path contains %d elements" % len(result.path)
print "Create Model..."
model = EndoscopyPathModel(result.path, fiducialsNode)
print "-> Model created"
# Update frame slider range
self.frameSlider.maximum = len(result.path) - 2
# Update flythrough variables
self.camera = self.camera
self.transform = model.transform
self.pathPlaneNormal = model.planeNormal
self.path = result.path
# Enable / Disable flythrough button
self.flythroughCollapsibleButton.enabled = len(result.path) > 0
def frameSliderValueChanged(self, newValue):
#print "frameSliderValueChanged:", newValue
self.flyTo(newValue)
def frameSkipSliderValueChanged(self, newValue):
#print "frameSkipSliderValueChanged:", newValue
self.skip = int(newValue)
def frameDelaySliderValueChanged(self, newValue):
#print "frameDelaySliderValueChanged:", newValue
self.timer.interval = newValue
def viewAngleSliderValueChanged(self, newValue):
if not self.cameraNode:
return
#print "viewAngleSliderValueChanged:", newValue
self.cameraNode.GetCamera().SetViewAngle(newValue)
def onPlayButtonToggled(self, checked):
if checked:
self.timer.start()
self.playButton.text = "Stop"
else:
self.timer.stop()
self.playButton.text = "Play"
def flyToNext(self):
currentStep = self.frameSlider.value
nextStep = currentStep + self.skip + 1
if nextStep > len(self.path) - 2:
nextStep = 0
self.frameSlider.value = nextStep
def flyTo(self, f):
""" Apply the fth step in the path to the global camera"""
if self.path:
f = int(f)
p = self.path[f]
wasModified = self.cameraNode.StartModify()
self.camera.SetPosition(*p)
foc = self.path[f+1]
self.camera.SetFocalPoint(*foc)
self.camera.OrthogonalizeViewUp()
toParent = vtk.vtkMatrix4x4()
self.transform.GetMatrixTransformToParent(toParent)
toParent.SetElement(0 ,3, p[0])
toParent.SetElement(1, 3, p[1])
toParent.SetElement(2, 3, p[2])
# Set up transform orientation component so that
# Z axis is aligned with view direction and
# Y vector is aligned with the curve's plane normal.
# This can be used for example to show a reformatted slice
# using with SlicerIGT extension's VolumeResliceDriver module.
import numpy as np
zVec = (foc-p)/np.linalg.norm(foc-p)
yVec = self.pathPlaneNormal
xVec = np.cross(yVec, zVec)
toParent.SetElement(0, 0, xVec[0])
toParent.SetElement(1, 0, xVec[1])
toParent.SetElement(2, 0, xVec[2])
toParent.SetElement(0, 1, yVec[0])
toParent.SetElement(1, 1, yVec[1])
toParent.SetElement(2, 1, yVec[2])
toParent.SetElement(0, 2, zVec[0])
toParent.SetElement(1, 2, zVec[1])
toParent.SetElement(2, 2, zVec[2])
self.transform.SetMatrixTransformToParent(toParent)
self.cameraNode.EndModify(wasModified)
self.cameraNode.ResetClippingRange()
class EndoscopyComputePath:
"""Compute path given a list of fiducials.
A Hermite spline interpolation is used. See http://en.wikipedia.org/wiki/Cubic_Hermite_spline
Example:
result = EndoscopyComputePath(fiducialListNode)
print "computer path has %d elements" % len(result.path)
"""
def __init__(self, fiducialListNode, dl = 0.5):
import numpy
self.dl = dl # desired world space step size (in mm)
self.dt = dl # current guess of parametric stepsize
self.fids = fiducialListNode
# hermite interpolation functions
self.h00 = lambda t: 2*t**3 - 3*t**2 + 1
self.h10 = lambda t: t**3 - 2*t**2 + t
self.h01 = lambda t:-2*t**3 + 3*t**2
self.h11 = lambda t: t**3 - t**2
# n is the number of control points in the piecewise curve
if self.fids.GetClassName() == "vtkMRMLAnnotationHierarchyNode":
# slicer4 style hierarchy nodes
collection = vtk.vtkCollection()
self.fids.GetChildrenDisplayableNodes(collection)
self.n = collection.GetNumberOfItems()
if self.n == 0:
return
self.p = numpy.zeros((self.n,3))
for i in xrange(self.n):
f = collection.GetItemAsObject(i)
coords = [0,0,0]
f.GetFiducialCoordinates(coords)
self.p[i] = coords
elif self.fids.GetClassName() == "vtkMRMLMarkupsFiducialNode":
# slicer4 Markups node
self.n = self.fids.GetNumberOfFiducials()
n = self.n
if n == 0:
return
# get fiducial positions
# sets self.p
self.p = numpy.zeros((n,3))
for i in xrange(n):
coord = [0.0, 0.0, 0.0]
self.fids.GetNthFiducialPosition(i, coord)
self.p[i] = coord
else:
# slicer3 style fiducial lists
self.n = self.fids.GetNumberOfFiducials()
n = self.n
if n == 0:
return
# get control point data
# sets self.p
self.p = numpy.zeros((n,3))
for i in xrange(n):
self.p[i] = self.fids.GetNthFiducialXYZ(i)
# calculate the tangent vectors
# - fm is forward difference
# - m is average of in and out vectors
# - first tangent is out vector, last is in vector
# - sets self.m
n = self.n
fm = numpy.zeros((n,3))
for i in xrange(0,n-1):
fm[i] = self.p[i+1] - self.p[i]
self.m = numpy.zeros((n,3))
for i in xrange(1,n-1):
self.m[i] = (fm[i-1] + fm[i]) / 2.
self.m[0] = fm[0]
self.m[n-1] = fm[n-2]
self.path = [self.p[0]]
self.calculatePath()
def calculatePath(self):
""" Generate a flight path for of steps of length dl """
#
# calculate the actual path
# - take steps of self.dl in world space
# -- if dl steps into next segment, take a step of size "remainder" in the new segment
# - put resulting points into self.path
#
n = self.n
segment = 0 # which first point of current segment
t = 0 # parametric current parametric increment
remainder = 0 # how much of dl isn't included in current step
while segment < n-1:
t, p, remainder = self.step(segment, t, self.dl)
if remainder != 0 or t == 1.:
segment += 1
t = 0
if segment < n-1:
t, p, remainder = self.step(segment, t, remainder)
self.path.append(p)
def point(self,segment,t):
return (self.h00(t)*self.p[segment] +
self.h10(t)*self.m[segment] +
self.h01(t)*self.p[segment+1] +
self.h11(t)*self.m[segment+1])
def step(self,segment,t,dl):
""" Take a step of dl and return the path point and new t
return:
t = new parametric coordinate after step
p = point after step
remainder = if step results in parametic coordinate > 1.0, then
this is the amount of world space not covered by step
"""
import numpy.linalg
p0 = self.path[self.path.__len__() - 1] # last element in path
remainder = 0
ratio = 100
count = 0
while abs(1. - ratio) > 0.05:
t1 = t + self.dt
pguess = self.point(segment,t1)
dist = numpy.linalg.norm(pguess - p0)
ratio = self.dl / dist
self.dt *= ratio
if self.dt < 0.00000001:
return
count += 1
if count > 500:
return (t1, pguess, 0)
if t1 > 1.:
t1 = 1.
p1 = self.point(segment, t1)
remainder = numpy.linalg.norm(p1 - pguess)
pguess = p1
return (t1, pguess, remainder)
class EndoscopyPathModel:
"""Create a vtkPolyData for a polyline:
- Add one point per path point.
- Add a single polyline
"""
def __init__(self, path, fiducialListNode):
fids = fiducialListNode
scene = slicer.mrmlScene
points = vtk.vtkPoints()
polyData = vtk.vtkPolyData()
polyData.SetPoints(points)
lines = vtk.vtkCellArray()
polyData.SetLines(lines)
linesIDArray = lines.GetData()
linesIDArray.Reset()
linesIDArray.InsertNextTuple1(0)
polygons = vtk.vtkCellArray()
polyData.SetPolys( polygons )
idArray = polygons.GetData()
idArray.Reset()
idArray.InsertNextTuple1(0)
for point in path:
pointIndex = points.InsertNextPoint(*point)
linesIDArray.InsertNextTuple1(pointIndex)
linesIDArray.SetTuple1( 0, linesIDArray.GetNumberOfTuples() - 1 )
lines.SetNumberOfCells(1)
import vtk.util.numpy_support as VN
pointsArray = VN.vtk_to_numpy(points.GetData())
self.planePosition, self.planeNormal = self.planeFit(pointsArray.T)
# Create model node
model = slicer.vtkMRMLModelNode()
model.SetScene(scene)
model.SetName(scene.GenerateUniqueName("Path-%s" % fids.GetName()))
model.SetAndObservePolyData(polyData)
# Create display node
modelDisplay = slicer.vtkMRMLModelDisplayNode()
modelDisplay.SetColor(1,1,0) # yellow
modelDisplay.SetScene(scene)
scene.AddNode(modelDisplay)
model.SetAndObserveDisplayNodeID(modelDisplay.GetID())
# Add to scene
scene.AddNode(model)
# Camera cursor
sphere = vtk.vtkSphereSource()
sphere.Update()
# Create model node
cursor = slicer.vtkMRMLModelNode()
cursor.SetScene(scene)
cursor.SetName(scene.GenerateUniqueName("Cursor-%s" % fids.GetName()))
cursor.SetPolyDataConnection(sphere.GetOutputPort())
# Create display node
cursorModelDisplay = slicer.vtkMRMLModelDisplayNode()
cursorModelDisplay.SetColor(1,0,0) # red
cursorModelDisplay.SetScene(scene)
scene.AddNode(cursorModelDisplay)
cursor.SetAndObserveDisplayNodeID(cursorModelDisplay.GetID())
# Add to scene
scene.AddNode(cursor)
# Create transform node
transform = slicer.vtkMRMLLinearTransformNode()
transform.SetName(scene.GenerateUniqueName("Transform-%s" % fids.GetName()))
scene.AddNode(transform)
cursor.SetAndObserveTransformNodeID(transform.GetID())
self.transform = transform
# source: http://stackoverflow.com/questions/12299540/plane-fitting-to-4-or-more-xyz-points
def planeFit(self, points):
"""
p, n = planeFit(points)
Given an array, points, of shape (d,...)
representing points in d-dimensional space,
fit an d-dimensional plane to the points.
Return a point, p, on the plane (the point-cloud centroid),
and the normal, n.
"""
import numpy as np
from numpy.linalg import svd
points = np.reshape(points, (np.shape(points)[0], -1)) # Collapse trialing dimensions
assert points.shape[0] <= points.shape[1], "There are only {} points in {} dimensions.".format(points.shape[1], points.shape[0])
ctr = points.mean(axis=1)
x = points - ctr[:,np.newaxis]
M = np.dot(x, x.T) # Could also use np.cov(x) here.
return ctr, svd(M)[0][:,-1]