neonatal_cortex.py
1 ##############################################################################
2 # Medical Image Registration ToolKit (MIRTK)
3 #
4 # Copyright 2016 Imperial College London
5 # Copyright 2016 Andreas Schuh
6 #
7 # Licensed under the Apache License, Version 2.0 (the "License");
8 # you may not use this file except in compliance with the License.
9 # You may obtain a copy of the License at
10 #
11 # http://www.apache.org/licenses/LICENSE-2.0
12 #
13 # Unless required by applicable law or agreed to in writing, software
14 # distributed under the License is distributed on an "AS IS" BASIS,
15 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 # See the License for the specific language governing permissions and
17 # limitations under the License.
18 ##############################################################################
19 
20 """Python module for reconstruction of neonatal cortex
21 
22 This module implements the deformable surfaces method for the reconstruction of
23 the neonatal cortex as detailed in the conference paper submission to ISBI 2017.
24 The recon-neonatal-cortex script provides a command-line tool for executing
25 the functions of this module to obtain explicit surface representations
26 of the inner and outer cortical surfaces given a Draw-EM segmentation
27 and the bias corrected T2-weighted (and T1-weighted if available) image(s).
28 
29 Note: The merge-surfaces tool failed when MIRTK was built with VTK 6.2.
30  Use a more recent version of VTK if you encounter such issue.
31 
32 """
33 
34 # Tested with MIRTK master branch commit f2f9bbb (Nov 14, 2016)
35 #
36 # Previous MIRTK versions (may not work with current script):
37 # - schuhschuh/MIRTK: develop branch commit 91e77be (Oct 10, 2016)
38 # - schuhschuh/MIRTK: develop branch commit 517e1e9 (Oct 19, 2016)
39 # - schuhschuh/MIRTK: develop branch commit e85a3eb (Oct 28, 2016)
40 # - schuhschuh/MIRTK: develop branch commit 115b40e (Nov 4, 2016)
41 
42 import os
43 import re
44 import sys
45 
46 from contextlib import contextmanager
47 
48 try:
49  from contextlib import ExitStack # Python 3
50 except:
51  from contextlib2 import ExitStack # Python 2 backport
52 
53 from mirtk.subprocess import flatten, check_call, check_output
54 from mirtk.subprocess import run as _run
55 
56 # ==============================================================================
57 # global settings
58 # ==============================================================================
59 
60 verbose = 0 # verbosity of output messages
61 showcmd = 0 # whether to print binary path and arguments of subprocesses
62 threads = 0 # maximum number of allowed threads for subprocess execution
63  # 0: use all available cores
64 debug = 0 # debug level, keep intermediate files when >0
65 force = True # whether to overwrite existing output files
66 
67 _cortex_mask_array = 'CortexMask'
68 _region_id_array = 'RegionId'
69 _collision_mask_array = 'CollisionMask'
70 _collision_type_array = 'CollisionType'
71 
72 # ==============================================================================
73 # enumerations
74 # ==============================================================================
75 
76 # ------------------------------------------------------------------------------
77 class Hemisphere(object):
78  """Enumeration of brain hemispheres"""
79  Unspecified = 0
80  Right = 1
81  Left = 2
82  Both = 3
83 
84 # ------------------------------------------------------------------------------
85 def hemi2str(hemi):
86  """Convert Hemisphere enumeration value to string"""
87  if hemi == Hemisphere.Right: return 'rh'
88  elif hemi == Hemisphere.Left: return 'lh'
89  else: return ''
90 
91 # ==============================================================================
92 # path utilities
93 # ==============================================================================
94 
95 # ------------------------------------------------------------------------------
96 def splitext(name):
97  """Split file name into name and extension."""
98  (base, ext) = os.path.splitext(name)
99  if ext.lower() == '.gz':
100  (base, fext) = os.path.splitext(base)
101  ext = fext + ext
102  return (base, ext)
103 
104 # ------------------------------------------------------------------------------
105 def splitname(name):
106  """Split temporary output file name into base name, incremental output ID, and extension."""
107  (base, ext) = splitext(name)
108  m = re.match('^(.*)-([0-9]+)$', base)
109  if m:
110  base = m.group(1)
111  num = int(m.group(2))
112  else:
113  num = 0
114  return (base, num, ext)
115 
116 # ------------------------------------------------------------------------------
117 def joinname(base, num, ext):
118  """Join temporary file name parts."""
119  return '{}-{}{}'.format(base, num, ext)
120 
121 # ------------------------------------------------------------------------------
122 def nextname(name, temp=None):
123  """Get next incremental output file path."""
124  (base, num, ext) = splitname(name)
125  if temp:
126  base = os.path.join(temp, os.path.basename(base))
127  return joinname(base, num+1, ext)
128 
129 # ------------------------------------------------------------------------------
130 def makedirs(name):
131  """Make directories for output file if not existent."""
132  path = os.path.dirname(name)
133  if not os.path.isdir(path):
134  os.makedirs(path)
135 
136 # ------------------------------------------------------------------------------
137 def rename(src, dst):
138  """Rename file."""
139  makedirs(dst)
140  os.rename(src, dst)
141 
142 # ------------------------------------------------------------------------------
143 def try_remove(name):
144  """Try to remove file, do not throw exception if it fails."""
145  if name:
146  try:
147  os.remove(name)
148  except:
149  return False
150  return True
151 
152 # ==============================================================================
153 # pipeline utilities
154 # ==============================================================================
155 
156 # ------------------------------------------------------------------------------
157 def iterable_or_int_to_list(arg):
158  """Given either an int or an iterable, returns list of items."""
159  if isinstance(arg, int):
160  return [arg]
161  else:
162  return list(arg)
163 
164 # ------------------------------------------------------------------------------
165 def run(tool, args=[], opts={}):
166  """Run MIRTK command with global `showcmd` flag and maximum allowed number of `threads`."""
167  _run(tool, args=args, opts=opts, showcmd=showcmd, threads=threads)
168 
169 # ------------------------------------------------------------------------------
170 @contextmanager
171 def output(name_or_func, delete=False):
172  """Context with (temporary) output file path.
173 
174  This context is used to ensure that a temporary file is deleted on
175  exit or when an exception occurs. Moreover, it is used to guarantee that an
176  output file which is the result of a number of intermediate processes which
177  write to the same file corresponds to the final output. When an error occurs
178  before the final output was written to the specified file, the output file
179  is removed when this context is exited to ensure that every output file has
180  a known final state. Alternatively, use different unique output file name
181  for each intermediate file and remove these intermediate files on exit.
182 
183  Parameters
184  ----------
185  name_or_func : str, def
186  File path or function which creates the output file and returns its path.
187  delete : bool
188  Whether the output file is temporary and should be deleted when done.
189 
190  Yields
191  ------
192  aname : str
193  Absolute path of output file.
194 
195  """
196  if isinstance(name_or_func, str): path = name_or_func
197  else: path = name_or_func()
198  if path:
199  try:
200  yield os.path.abspath(path)
201  except BaseException as e:
202  if debug < 1:
203  try_remove(path)
204  raise e
205  if debug == 0 and path and delete:
206  try_remove(path)
207  else:
208  raise Exception("Invalid file path")
209 
210 # ------------------------------------------------------------------------------
211 def push_output(stack, name_or_func, delete=True):
212  """Perform interim processing step with auto-file removal upon exit."""
213  return stack.enter_context(output(name_or_func, delete=delete))
214 
215 # ------------------------------------------------------------------------------
216 def get_voxel_size(image):
217  """Get voxel size of image file."""
218  info = check_output(['info', image])
219  match = re.search('Spacing:\s+([0-9][0-9.]*)\s*x\s*([0-9][0-9.]*)\s*x\s*([0-9][0-9.]*)', info)
220  try:
221  dx = float(match.group(1))
222  dy = float(match.group(2))
223  dz = float(match.group(3))
224  except:
225  raise Exception("Failed to determine voxel size of image: {}".format(image))
226  return (dx, dy, dz)
227 
228 # ------------------------------------------------------------------------------
229 def get_surface_property(name_or_info, props, mesh=False, topology=False, opts={}):
230  """Get surface property values"""
231  values = []
232  if os.path.splitext(name_or_info)[1] in ['.vtp', '.vtk', '.stl', '.obj']:
233  info = evaluate_surface(name_or_info, mesh=mesh, topology=topology, opts=opts)
234  else:
235  info = name_or_info
236  if isinstance(props, str):
237  props = [props]
238  for prop in props:
239  regex = re.compile('^\s*' + re.escape(prop) + r'\s*=\s*(.+)\s*$', re.MULTILINE)
240  match = regex.search(info)
241  if not match:
242  if debug > 0:
243  sys.stderr.write(info)
244  raise Exception("Missing surface property: " + prop)
245  values.append(match.group(1))
246  if len(values) == 1:
247  return values[0]
248  return values
249 
250 # ------------------------------------------------------------------------------
251 def get_num_components(name_or_info):
252  """Get number of connected surface components"""
253  return int(get_surface_property(name_or_info, 'No. of components', mesh=True))
254 
255 # ------------------------------------------------------------------------------
256 def get_num_boundaries(name_or_info):
257  """Get number of surface boundaries"""
258  return int(get_surface_property(name_or_info, 'No. of boundary segments', mesh=True))
259 
260 # ------------------------------------------------------------------------------
261 def get_euler_characteristic(name_or_info):
262  """Get Euler characteristic of surface mesh"""
263  return int(get_surface_property(name_or_info, 'Euler characteristic', topology=True))
264 
265 # ------------------------------------------------------------------------------
266 def get_genus(name_or_info):
267  """Get Genus of surface mesh"""
268  return float(get_surface_property(name_or_info, 'Genus', topology=True))
269 
270 # ==============================================================================
271 # subroutines
272 # ==============================================================================
273 
274 # ------------------------------------------------------------------------------
275 def invert_mask(iname, oname=None):
276  """Invert a binary mask."""
277  if oname:
278  makedirs(oname)
279  else:
280  oname = nextname(iname)
281  run('calculate-element-wise', args=[iname], opts=[('binarize', [0, 0]), ('out', oname, 'binary')])
282  return oname
283 
284 # ------------------------------------------------------------------------------
285 def close_image(iname, oname=None, iterations=1, connectivity=18):
286  """Close (binary) image using morphological dilation followed by erosion."""
287  if oname:
288  makedirs(oname)
289  else:
290  oname = nextname(iname)
291  run('close-image', args=[iname, oname], opts={'iterations': iterations, 'connectivity': connectivity})
292  return oname
293 
294 # ------------------------------------------------------------------------------
295 def del_mesh_attr(iname, oname=None, **opts):
296  """Delete surface mesh attributes."""
297  if not oname:
298  oname = iname
299  run('delete-pointset-attributes', args=[iname, oname], opts=opts)
300 
301 # ------------------------------------------------------------------------------
302 def evaluate_surface(name, oname=None, mesh=False, topology=False, intersections=False, collisions=0, opts={}):
303  """Evaluate properties of surface mesh"""
304  argv = ['evaluate-surface-mesh', name]
305  if oname:
306  argv.extend([oname, '-v'])
307  argv.extend(['-threads', str(threads)])
308  if mesh: argv.append('-attr')
309  if topology: argv.append('-topology')
310  if collisions > 0 or intersections:
311  argv.extend(['-collisions', collisions])
312  if len(opts) > 0:
313  if isinstance(opts, list):
314  for item in opts:
315  if isinstance(item, (tuple, list)):
316  opt = item[0]
317  arg = flatten(item[1:])
318  else:
319  opt = item
320  arg = None
321  if not opt.startswith('-'):
322  opt = '-' + opt
323  argv.append(opt)
324  if not arg is None:
325  argv.extend(flatten(arg))
326  else:
327  for opt, arg in opts.items():
328  if not opt.startswith('-'):
329  opt = '-' + opt
330  argv.append(opt)
331  if not arg is None:
332  argv.extend(flatten(arg))
333  return check_output(argv, verbose=showcmd)
334 
335 # ------------------------------------------------------------------------------
336 def smooth_surface(iname, oname=None, iterations=1, lambda_value=1, mu=None, mask=None, weighting='combinatorial', excl_node=False):
337  if not oname: oname = nextname(iname)
338  argv = ['smooth-surface', iname, oname, '-threads', str(threads), '-iterations', iterations, '-' + weighting]
339  if mask: argv.extend(['-mask', mask])
340  if excl_node: argv.append('-exclnode')
341  else: argv.append('-inclnode')
342  argv.extend(['-lambda', lambda_value])
343  if mu: argv.extend(['-mu', mu])
344  check_call(argv, verbose=showcmd)
345  return oname
346 
347 # ------------------------------------------------------------------------------
348 def check_intersections(iname, oname=None):
349  """Check surface mesh for triangle/triangle intersections."""
350  argv = ['evaluate-surface-mesh', iname]
351  if oname:
352  argv.extend([oname, '-v'])
353  argv.extend(['-threads', threads, '-collisions', 0])
354  info = check_output(argv, verbose=showcmd)
355  match = re.search('No\. of self-intersections\s*=\s*(\d+)', info)
356  return int(match.group(1))
357 
358 # ------------------------------------------------------------------------------
359 def remove_intersections(iname, oname=None, mask=None, max_attempt=10, smooth_iter=5, smooth_lambda=1):
360  """Remove intersections of surface mesh triangles.
361 
362  .. seealso:: MRISremoveIntersections function of FreeSurfer (dev/utils/mrisurf.c)
363  """
364  if not oname:
365  oname = nextname(iname)
366  with output(oname):
367  itr = nbr = 1
368  cur = check_intersections(iname, oname)
369  while cur > 0:
370  itr += 1
371  if itr == 1 and verbose > 0:
372  print(("Trying to resolve {} remaining self-intersections of {}".format(cur, iname)))
373  if itr > max_attempt:
374  raise Exception("Failed to resolve self-intersections of {}".format(iname))
375  run('dilate-scalars', args=[oname, oname], opts={'array': _collision_mask_array, 'iterations': nbr})
376  if mask:
377  run('calculate-element-wise', args=[oname], opts=[('scalars', _collision_mask_array), ('mul', mask), ('out', oname)])
378  smooth_surface(oname, oname, iterations=smooth_iter, lambda_value=smooth_lambda,
379  weighting='combinatorial', mask=_collision_mask_array, excl_node=False)
380  pre = cur
381  cur = check_intersections(oname, oname)
382  if cur >= pre: nbr += 1
383  del_mesh_attr(oname, oname, pointdata=_collision_mask_array, celldata=_collision_type_array)
384  return oname
385 
386 # ------------------------------------------------------------------------------
387 def remove_white_pial_intersections(iname, oname=None, mask=None, max_attempt=10, smooth_iter=1, smooth_lambda=1, region_id_array=_region_id_array):
388  """Smooth white and pial surfaces in an attempt to remove intersections between triangles of white and pial surface."""
389  iname = os.path.abspath(iname)
390  if oname:
391  oname = os.path.abspath(oname)
392  else:
393  oname = nextname(iname)
394  with output(oname):
395  # constants
396  weighting = 'combinatorial'
397  smooth_mask_array = 'SmoothMask'
398  curvature_array = 'Curvature'
399  # check if triangles intersect
400  cur = check_intersections(iname, oname)
401  if cur > 0:
402  # compute surface curvature
403  run('calculate-surface-attributes', args=[oname, oname], opts={'H': curvature_array, 'vtk-curvatures': None})
404  run('copy-pointset-attributes', args=[oname, oname], opts={'celldata-as-pointdata': region_id_array, 'unanimous': None})
405  itr = nbr = 1
406  while cur > 0:
407  itr += 1
408  if itr == 1 and verbose > 0:
409  print(("Trying to resolve {} remaining self-intersections of {}".format(cur, iname)))
410  if itr > max_attempt:
411  raise Exception("Failed to resolve self-intersections of {}".format(iname))
412  # smooth intersected convex regions (sulci) of pial surface
413  run('calculate-element-wise', args=[oname], opts=[('point-data', region_id_array), ('label', 3, 4),
414  ('mask', _collision_mask_array), ('set', 1), ('pad', 0),
415  ('mul', curvature_array), ('threshold-ge', 0), ('pad', 0),
416  ('out', oname, 'binary', smooth_mask_array)])
417  run('dilate-scalars', args=[oname, oname], opts={'array': smooth_mask_array, 'iterations': nbr})
418  if mask:
419  run('calculate-element-wise', args=[oname], opts=[('scalars', smooth_mask_array), ('mul', mask), ('out', oname)])
420  smooth_surface(oname, oname, iterations=smooth_iter, lambda_value=smooth_lambda,
421  weighting=weighting, mask=smooth_mask_array, excl_node=False)
422  if check_intersections(oname, oname) > 0:
423  # smooth intersected concave regions (gyri) of white surface
424  run('calculate-element-wise', args=[oname], opts=[('point-data', region_id_array), ('label', 1, 2),
425  ('mask', _collision_mask_array), ('set', 1), ('pad', 0),
426  ('mul', curvature_array), ('threshold-le', 0), ('pad', 0),
427  ('out', oname, 'binary', smooth_mask_array)])
428  run('dilate-scalars', args=[oname, oname], opts={'array': smooth_mask_array, 'iterations': nbr})
429  if mask:
430  run('calculate-element-wise', args=[oname], opts=[('scalars', smooth_mask_array), ('mul', mask), ('out', oname)])
431  smooth_surface(oname, oname, iterations=smooth_iter, lambda_value=smooth_lambda,
432  weighting=weighting, mask=smooth_mask_array, excl_node=False)
433  # re-evaluate intersections
434  pre = cur
435  cur = check_intersections(oname, oname)
436  if cur >= pre: nbr += 1
437  # remove auxiliary attributes
438  del_mesh_attr(oname, pointdata=[region_id_array, curvature_array, smooth_mask_array, _collision_mask_array], celldata=_collision_type_array)
439  return oname
440 
441 # ------------------------------------------------------------------------------
442 def project_mask(iname, oname, mask, name, dilation=0, invert=False, fill=False):
443  """Project binary mask onto surface."""
444  makedirs(oname)
445  run('project-onto-surface', args=[iname, oname],
446  opts={'labels': mask, 'fill': fill, 'dilation-radius': dilation, 'name': name})
447  if invert:
448  run('calculate-element-wise', args=[oname],
449  opts=[('scalars', name), ('threshold-inside', [0, 0]), ('set', 0), ('pad', 1), ('out', oname)])
450 
451 # ------------------------------------------------------------------------------
452 def calculate_distance_map(iname, oname=None, temp=None, distance='euclidean', isotropic=1):
453  """Calculate signed distance map from binary object mask."""
454  if not oname:
455  if not temp:
456  temp = os.path.dirname(iname)
457  oname = splitname(os.path.basename(iname))[0]
458  if oname.endswith('-mask'):
459  oname = oname[0:-5]
460  oname = os.path.join(temp, '{}-dmap.nii.gz'.format(oname))
461  makedirs(oname)
462  run('calculate-distance-map', args=[iname, oname], opts={'distance': distance, 'isotropic': isotropic})
463  return oname
464 
465 # ------------------------------------------------------------------------------
466 def extract_isosurface(iname, oname=None, temp=None, isoval=0, blur=0, close=True):
467  """Extract iso-surface from image."""
468  if not oname:
469  if not temp:
470  temp = os.path.dirname(iname)
471  oname = splitname(os.path.basename(iname))[0]
472  if oname.endswith('-dmap') or oname.endswith('-mask'):
473  oname = oname[0:-5]
474  oname = '{}-iso.vtp'.format(oname)
475  oname = os.path.join(temp, oname)
476  makedirs(oname)
477  run('extract-surface', args=[iname, oname], opts={'isovalue': isoval, 'blur': blur, 'close': close})
478  return oname
479 
480 # ------------------------------------------------------------------------------
481 def remesh_surface(iname, oname=None, temp=None, edge_length=0, min_edge_length=0, max_edge_length=10):
482  """Remesh surface."""
483  if not oname:
484  if not temp:
485  temp = os.path.dirname(iname)
486  oname = os.path.join(temp, nextname(os.path.basename(iname)))
487  makedirs(oname)
488  if edge_length > 0:
489  min_edge_length = edge_length
490  max_edge_length = edge_length
491  run('remesh-surface', args=[iname, oname], opts={'min-edge-length': min_edge_length, 'max-edge-length': max_edge_length})
492  return oname
493 
494 # ------------------------------------------------------------------------------
495 def get_convex_hull(iname, oname=None, temp=None, remeshing=10, edge_length=1, smoothing=100):
496  """Get convex hull of surface mesh."""
497  if not oname:
498  oname = splitname(iname)[0]
499  if oname.endswith('-iso'):
500  oname = oname[0:-4]
501  oname = '{}-hull.vtp'.format(oname)
502  if force:
503  try_remove(oname)
504  if not os.path.isfile(oname):
505  with ExitStack() as stack:
506  # compute convex hull of isosurface
507  hull = push_output(stack, nextname(oname, temp=temp))
508  run('extract-pointset-surface', opts={'input': iname, 'output': hull, 'hull': None})
509  # iteratively remesh and smooth surface to remove small self-intersections
510  # which may have been introduced by the hull tesselation / local remeshing operations
511  for i in range(0, remeshing):
512  name = push_output(stack, nextname(hull))
513  run('remesh-surface', args=[hull, name], opts={'edge-length': edge_length})
514  run('smooth-surface', args=[name, name],
515  opts={'combinatorial': None, 'exclnode': None,
516  'iterations': smoothing, 'lambda': .33, 'mu': -.34})
517  if debug < 2:
518  try_remove(hull)
519  hull = name
520  rename(hull, oname)
521  return oname
522 
523 # ------------------------------------------------------------------------------
524 def extract_convex_hull(iname, oname=None, temp=None, isoval=0, blur=0):
525  """Calculate convex hull of implicit surface."""
526  if not temp:
527  temp = os.path.dirname(iname)
528  base = splitname(os.path.basename(iname))[0]
529  if base.endswith('-dmap') or base.endswith('-mask'):
530  base = base[0:-5]
531  if not oname:
532  oname = os.path.join(temp, '{}-hull.vtp'.format(base))
533  if force:
534  try_remove(oname)
535  if not os.path.isfile(oname):
536  makedirs(oname)
537  with output(extract_isosurface(iname, temp=temp, isoval=isoval, blur=blur), delete=True) as iso:
538  get_convex_hull(iso, oname, temp=temp)
539  return oname
540 
541 # ------------------------------------------------------------------------------
542 def add_corpus_callosum_mask(iname, mask, oname=None):
543  """Add ImplicitSurfaceFillMask point data array to surface file.
544 
545  This mask is considered by the ImplicitSurfaceDistance force when deforming
546  the convex hull towards the WM segmentation boundary. Distance values of points
547  with a zero mask value are excluded from the clustering based hole filling.
548  This is to allow the surface to deform into the sulcus next to corpus callosum.
549  """
550  if not oname:
551  oname = nextname(iname)
552  if force:
553  try_remove(oname)
554  if not os.path.isfile(oname):
555  makedirs(oname)
556  with output(oname):
557  project_mask(iname, oname, mask, name='ImplicitSurfaceFillMask', dilation=20, invert=True)
558  return oname
559 
560 # ------------------------------------------------------------------------------
561 def del_corpus_callosum_mask(iname, oname=None):
562  """Remove ImplicitSurfaceFillMask again."""
563  if not oname:
564  oname = nextname(iname)
565  if force:
566  try_remove(oname)
567  if not os.path.isfile(oname):
568  makedirs(oname)
569  del_mesh_attr(iname, oname, pointdata='ImplicitSurfaceFillMask')
570  return oname
571 
572 # ------------------------------------------------------------------------------
573 # TODO: Option to require minimum distance from medial cutting plane.
574 def add_cortex_mask(iname, mask, name=_cortex_mask_array, region_id_array=_region_id_array, oname=None):
575  """Add a CortexMask cell data array to the surface file."""
576  if not oname:
577  oname = nextname(iname)
578  if debug > 0:
579  assert os.path.realpath(iname) != os.path.realpath(oname), "iname != oname"
580  if force or not os.path.isfile(oname):
581  makedirs(oname)
582  with output(oname):
583  run('project-onto-surface', args=[iname, oname],
584  opts={'labels': mask, 'name': name, 'dilation-radius': .5,
585  'point-data': False, 'cell-data': True, 'fill': True,
586  'max-hole-size': 100, 'min-size': 1000, 'smooth': 2})
587  run('erode-scalars', args=[oname, oname], opts={'cell-data': name, 'iterations': 2})
588  run('calculate-element-wise', args=[oname],
589  opts=[('cell-data', region_id_array), ('label', 7), ('set', 0), ('pad', 1),
590  'reset-mask', ('mul', name), ('out', oname, 'binary', name)])
591  run('calculate-surface-attributes', args=[oname, oname],
592  opts={'cell-labels': region_id_array, 'border-mask': 'RegionBorder'})
593  run('calculate-element-wise', args=[oname],
594  opts=[('cell-data', 'RegionBorder'),
595  ('threshold', 1), ('set', 0), ('pad', 1), 'reset-mask',
596  ('mul', region_id_array), ('mul', name), ('binarize', 1),
597  ('out', oname, 'char', name)])
598  run('evaluate-surface-mesh', args=[oname, oname], opts={'where': name, 'gt': 0})
599  run('calculate-element-wise', args=[oname],
600  opts=[('cell-data', 'ComponentId'), ('binarize', 1, 2),
601  ('mul', name), ('out', oname, 'binary', name)])
602  run('calculate-element-wise', args=[oname],
603  opts=[('cell-data', region_id_array),
604  ('label', 1), ('mul', name), ('threshold-gt', 0), ('add', 5),
605  'reset-mask',
606  ('label', 2), ('mul', name), ('threshold-gt', 0), ('add', 6),
607  ('out', oname)])
608  del_mesh_attr(oname, oname, name=['BoundaryMask', 'ComponentId', 'RegionBorder', 'DuplicatedMask'])
609  return oname
610 
611 # ------------------------------------------------------------------------------
612 def append_surfaces(name, surfaces, merge=True, tol=1e-6):
613  """Append surface meshes into single mesh file.
614 
615  Parameters
616  ----------
617  name : str
618  File path of output mesh.
619  surfaces : list
620  List of file paths of surface meshes to combine.
621  merge : bool
622  Whether to merge points of surface meshes.
623  tol : float
624  Maximum distance between points to be merged.
625 
626  Returns
627  -------
628  aname : str
629  Absolute file path of output mesh.
630 
631  """
632  name = os.path.abspath(name)
633  makedirs(name)
634  args = surfaces
635  args.append(name)
636  opts = {}
637  if merge: opts['merge'] = tol
638  run('convert-pointset', args=args, opts=opts)
639  return name
640 
641 # ------------------------------------------------------------------------------
642 def white_refinement_mask(name, subcortex_mask):
643  """Create image foreground mask used for reconstruction of WM/cGM surface."""
644  with output(invert_mask(subcortex_mask, nextname(name)), delete=True) as mask:
645  close_image(mask, name, iterations=2, connectivity=18)
646  return name
647 
648 # ------------------------------------------------------------------------------
649 def deform_mesh(iname, oname=None, temp=None, opts={}):
650  """Deform mesh with the specified options."""
651  if not temp:
652  temp = os.path.dirname(iname)
653  if not oname:
654  oname = os.path.join(temp, os.path.basename(nextname(iname)))
655  if force:
656  try_remove(oname)
657  if not os.path.isfile(oname):
658  base = splitext(os.path.basename(oname))[0]
659  debug_prefix = os.path.join(temp, base + '-')
660  if debug > 1:
661  opts['debug'] = (debug-2 if debug > 3 else 1)
662  opts['debug-interval'] = (1 if debug > 2 else 10)
663  opts['debug-prefix'] = debug_prefix
664  opts['level-prefix'] = False
665  fname_prefix = debug_prefix + 'output_'
666  for fname in os.listdir(temp):
667  if fname.startswith(fname_prefix):
668  try_remove(os.path.join(temp, fname))
669  makedirs(oname)
670  run('deform-mesh', args=[iname, oname], opts=opts)
671  return oname
672 
673 # ------------------------------------------------------------------------------
674 def extract_surface(iname, oname, labels, array=_region_id_array):
675  """Extract sub-surface from combined surface mesh."""
676  makedirs(oname)
677  opts = [('normals', True), ('where', array), 'or']
678  opts.extend([('eq', label) for label in labels])
679  run('extract-pointset-cells', args=[iname, oname], opts=opts)
680 
681 # ==============================================================================
682 # image segmentation
683 # ==============================================================================
684 
685 # ------------------------------------------------------------------------------
686 def binarize(name, segmentation, labels=[], image=None, interp='linear', threshold=.5, temp=None):
687  """Make binary mask from label image.
688 
689  Parameters
690  ----------
691  name : str
692  Path of output image file.
693  segmentation : str
694  Path of segmentation label image file.
695  labels : list
696  List of segmentation labels.
697  image : str, optional
698  Path of reference image file. When specified, the binary image is
699  resampled on this reference image grid using the specified
700  interpolation followed by thresholding using the given `threshold`
701  value unless `interp='nn'`.
702  threshold : float
703  Threshold used to binarize a non-nearest neighbor interpolated
704  resampled binary image when reference `image` is given.
705 
706  Returns
707  -------
708  mask : str,
709  Absolute path of output image file.
710 
711  """
712  if debug > 0:
713  assert name, "Invalid 'name' argument"
714  assert segmentation, "Invalid 'segmentation' argument"
715  name = os.path.abspath(name)
716  if temp:
717  temp = os.path.abspath(temp)
718  else:
719  temp = os.path.dirname(name)
720  with ExitStack() as stack:
721  if image:
722  mask = push_output(stack, nextname(name))
723  else:
724  mask = name
725  makedirs(mask)
726  # write binary image
727  if not isinstance(labels, int) and len(labels) == 0:
728  opts=[('mask', 0)]
729  else:
730  opts=[('label', labels)]
731  opts.extend([('set', 1), ('pad', 0), ('out', mask, 'binary')])
732  run('calculate-element-wise', args=[segmentation], opts=opts)
733  # resample mask on target image grid
734  if image:
735  if interp == 'nn':
736  run('transform-image', args=[mask, name], opts={'target': image, 'dofin': 'Id', 'interp': interp, 'type': 'binary'})
737  else:
738  resampled_mask = push_output(stack, nextname(mask))
739  run('transform-image', args=[mask, resampled_mask], opts={'target': image, 'dofin': 'Id', 'interp': interp, 'type': 'float'})
740  run('calculate-element-wise', args=[resampled_mask], opts=[('threshold-le', threshold), ('set', 1), ('out', [name, 'binary'])])
741  return name
742 
743 # ------------------------------------------------------------------------------
744 def binarize_cortex(regions, name=None, temp=None):
745  """Make binary cortex mask from regions label image."""
746  if debug > 0:
747  assert regions, "Invalid 'regions' argument"
748  if not name:
749  name = 'cortex-mask.nii.gz'
750  if not temp:
751  temp = os.path.dirname(regions)
752  name = os.path.join(temp, name)
753  return binarize(name=name, segmentation=regions, labels=1)
754 
755 # ------------------------------------------------------------------------------
756 def binarize_white_matter(regions, hemisphere=Hemisphere.Unspecified, name=None, temp=None):
757  """Make binary white matter mask from regions label image.
758 
759  Parameters
760  ----------
761  name : str
762  Relative or absolute path of output mask.
763  regions : str
764  File path of regions image with right and left cerebrum labelled.
765  hemisphere : Hemisphere
766  Which hemisphere of the cerebral cortex to binarize.
767 
768  Returns
769  -------
770  aname : str
771  Absolute path of binary white matter mask.
772 
773  """
774  if debug > 0:
775  assert regions, "Invalid 'regions' argument"
776  if not name:
777  suffix = hemi2str(hemisphere)
778  if suffix != '':
779  suffix = '-' + suffix
780  name = 'white{}-mask.nii.gz'.format(suffix)
781  if not temp:
782  temp = os.path.dirname(regions)
783  name = os.path.join(temp, name)
784  if hemisphere == Hemisphere.Right: labels = 2
785  elif hemisphere == Hemisphere.Left: labels = 3
786  else: labels = [2, 3]
787  return binarize(name=name, segmentation=regions, labels=labels)
788 
789 # ------------------------------------------------------------------------------
790 def binarize_brainstem_plus_cerebellum(regions, name=None, temp=None):
791  """Make binary brainstem plus cerebellum mask from regions label image."""
792  if debug > 0:
793  assert regions, "Invalid 'regions' argument"
794  if not name:
795  name = 'brainstem+cerebellum-mask.nii.gz'
796  if not temp:
797  temp = os.path.dirname(regions)
798  name = os.path.join(temp, name)
799  makedirs(name)
800  return binarize(name=name, segmentation=regions, labels=[4, 5])
801 
802 # ------------------------------------------------------------------------------
803 def subdivide_brain(name, segmentation, white_labels, cortex_labels, right_labels, left_labels,
804  subcortex_labels=[], subcortex_closing=5,
805  brainstem_labels=[], brainstem_closing=5,
806  cerebellum_labels=[], cerebellum_closing=5,
807  tissues=None, brain_mask=None, merge_bs_cb=True,
808  cortical_hull_dmap=None, temp=None):
809  """Subdivide brain into major building blocks, nicely cut disjoint regions.
810 
811  Parameters
812  ----------
813  name : str
814  Path of output brain regions image file.
815  segmentation : str
816  Path of brain segmentation label image file. May be a structural or tissue
817  segmentation. If a separate tissue segmentation label image should be used
818  for delineating the white matter and gray matter, provide also a `tissues` image.
819  white_labels : int, str, list
820  Label(s) of `segmentation` corresponding to interior of white matter surface.
821  When a `tissues` image is specified, these labels are considered to
822  be corresponding to the white matter tissue label instead.
823  cortex_labels : int, str, list
824  Label(s) of `segmentation` corresponding to cortical gray matter.
825  Can contain strings of label ranges such as '1..4' instead of int's only.
826  When a `tissues` image is specified, these labels are considered to
827  be corresponding to the gray matter tissue label instead.
828  right_labels : int, str, list
829  Label(s) of `segmentation` corresponding to structures of right hemisphere.
830  left_labels : int, str, list
831  Label(s) of `segmentation` corresponding to structures of left hemisphere.
832  subcortex_labels : int, str, list, optional
833  Label(s) of `segmentation` corresponding to subcortical structures.
834  subcortex_closing : int
835  Number of iterations of morphological closing applied to subcortical structures mask.
836  brainstem_labels : int, str, list, optional
837  Label(s) of `segmentation` corresponding to brainstem.
838  brainstem_closing : int
839  Number of iterations of morphological closing applied to brainstem mask.
840  cerebellum_labels : int, str, list, optional
841  Label(s) of `segmentation` corresponding to cerebellum.
842  cerebellum_closing : int
843  Number of iterations of morphological closing applied to cerebellum mask.
844  tissues : str
845  Path of tissue segmentation used for white and gray matter
846  instead of the (structural) brain `segmentation`.
847  brain_mask : str
848  Path of brain extraction mask used to ensure that the resampled
849  image grid contains the whole of the brain.
850  merge_bs_cb : bool
851  Whether to merge brainstem and cerebellum labels into a single output region.
852  cortical_hull_dmap : str, optional
853  Path of output cortical hull distance map image file.
854  temp : str
855  Path of temporary directory for segmentation masks of white or gray
856  matter tissue, respectively, when `tissues` segmentation provided.
857 
858  """
859  name = os.path.abspath(name)
860  if temp:
861  temp = os.path.abspath(temp)
862  else:
863  temp = os.path.dirname(name)
864  white_labels = iterable_or_int_to_list(white_labels)
865  cortex_labels = iterable_or_int_to_list(cortex_labels)
866  right_labels = iterable_or_int_to_list(right_labels)
867  left_labels = iterable_or_int_to_list(left_labels)
868  subcortex_labels = iterable_or_int_to_list(subcortex_labels)
869  brainstem_labels = iterable_or_int_to_list(brainstem_labels)
870  cerebellum_labels = iterable_or_int_to_list(cerebellum_labels)
871  with ExitStack() as stack:
872  if tissues:
873  white_mask = os.path.join(temp, 'inner-surface-mask.nii.gz')
874  cortex_mask = os.path.join(temp, 'outer-surface-mask.nii.gz')
875  white_labels = push_output(stack, binarize(name=white_mask, segmentation=tissues, labels=white_labels))
876  cortex_labels = push_output(stack, binarize(name=cortex_mask, segmentation=tissues, labels=cortex_labels))
877  opts = {
878  'rh': right_labels, 'lh': left_labels,
879  'wm': white_labels, 'gm': cortex_labels,
880  'sb': subcortex_labels,
881  'bs': brainstem_labels,
882  'cb': cerebellum_labels,
883  'bs+cb': merge_bs_cb,
884  'subcortical-closing': subcortex_closing,
885  'brainstem-closing': brainstem_closing,
886  'cerebellum-closing': cerebellum_closing
887  }
888  if brain_mask:
889  opts['fg'] = os.path.abspath(brain_mask)
890  if len(subcortex_labels) > 0:
891  opts['sb'] = subcortex_labels
892  if len(brainstem_labels) > 0:
893  opts['bs'] = brainstem_labels
894  if len(cerebellum_labels) > 0:
895  opts['cb'] = cerebellum_labels
896  if cortical_hull_dmap:
897  cortical_hull_dmap = os.path.abspath(cortical_hull_dmap)
898  opts['output-inner-cortical-distance'] = cortical_hull_dmap
899  makedirs(cortical_hull_dmap)
900  makedirs(name)
901  run('subdivide-brain-image', args=[segmentation, name], opts=opts)
902  return name
903 
904 # ==============================================================================
905 # surface reconstruction
906 # ==============================================================================
907 
908 # ------------------------------------------------------------------------------
909 def recon_boundary(name, mask, blur=1, edge_length=1, temp=None):
910  """Reconstruct surface of mask."""
911  if debug > 0:
912  assert name, "Invalid 'name' argument"
913  assert mask, "Invalid 'mask' argument"
914  makedirs(name)
915  with ExitStack() as stack:
916  dmap = push_output(stack, calculate_distance_map(mask, temp=temp))
917  surf = push_output(stack, extract_isosurface(dmap, blur=blur))
918  return remesh_surface(surf, oname=name, edge_length=edge_length)
919 
920 # ------------------------------------------------------------------------------
921 def recon_brain_surface(name, mask, temp=None):
922  """Reconstruct surface of brain mask."""
923  return recon_boundary(name, mask, blur=2, temp=temp)
924 
925 # ------------------------------------------------------------------------------
926 def recon_brainstem_plus_cerebellum_surface(name, mask=None, regions=None,
927  region_id_array=_region_id_array,
928  cortex_mask_array=_cortex_mask_array,
929  temp=None):
930  """Reconstruct surface of merged brainstem plus cerebellum region."""
931  if debug > 0:
932  assert mask or regions, "Either 'regions' or 'mask' argument required"
933  name = os.path.abspath(name)
934  if temp:
935  temp = os.path.abspath(temp)
936  else:
937  temp = os.path.dirname(name)
938  with ExitStack() as stack:
939  if not mask:
940  mask = push_output(stack, binarize_brainstem_plus_cerebellum(regions, temp=temp))
941  mesh = push_output(stack, nextname(name, temp=temp))
942  recon_boundary(mesh, mask, blur=1, temp=temp)
943  if region_id_array:
944  run('project-onto-surface', args=[mesh, mesh],
945  opts={'constant': 7, 'name': region_id_array, 'type': 'short',
946  'point-data': False, 'cell-data': True})
947  if cortex_mask_array:
948  run('project-onto-surface', args=[mesh, mesh],
949  opts={'constant': 0, 'name': cortex_mask_array, 'type': 'uchar',
950  'point-data': False, 'cell-data': True})
951  rename(mesh, name)
952  return name
953 
954 # ------------------------------------------------------------------------------
955 def recon_cortical_surface(name, mask=None, regions=None,
956  hemisphere=Hemisphere.Unspecified,
957  corpus_callosum_mask=None, temp=None):
958  """Reconstruct initial surface of right and/or left cerebrum.
959 
960  The input is either a regions label image generated by `subdivide-brain-image`
961  or a custom binary mask created from a given brain segmentation. The boundary of
962  the mask must be sufficiently close to the WM/cGM interface.
963 
964  Attention: Order of arguments may differ from the order of parameter help below!
965  Pass parameter values as keyword argument, e.g., regions='regions.nii.gz'.
966 
967  Parameters
968  ----------
969  name : str
970  File path of output surface file.
971  mask : str, optional
972  File path of binary mask to use instead of `regions` segments.
973  When this argument is given, both `regions` and `hemisphere` are ignored.
974  regions : str
975  File path of regions image with right and left cerebrum labelled.
976  Ignored when a custom `mask` image is given instead.
977  hemisphere : Hemisphere
978  Which hemisphere of the cerebral cortex to reconstruct. If Hemisphere.Both,
979  the surfaces of both hemispheres are reconstructed and the two file paths
980  returned as a 2-tuple. When Hemisphere.Unspecified, the hemisphere is
981  determined from the output file `name` if possible.
982  corpus_callosum_mask : str, optional
983  File path of binary mask of segmented corpus callosum.
984  Used to disable clustering based hole filling of implicit
985  surface distance for points nearby the corpus callosum.
986  temp : str
987  Path of temporary working directory. Intermediate files are written to
988  this directory and deleted on exit unless the global `debug` flag is set.
989  When not specified, intermediate files are written to the same directory
990  as the output mesh file, i.e., the directory of `name`.
991 
992  Returns
993  -------
994  path : str, tuple
995  Absolute path of output surface mesh file.
996  A 2-tuple of (right, left) surface mesh file paths is returned when
997  no `mask` but a `regions` image was given with `hemisphere=Hemisphere.Both`.
998 
999  """
1000  if debug > 0:
1001  assert name, "Invalid 'name' argument"
1002  assert mask or regions, "Either 'regions' or 'mask' argument required"
1003  name = os.path.abspath(name)
1004  (base, ext) = splitext(name)
1005  if not ext:
1006  ext = '.vtp'
1007  name = base + ext
1008  if temp:
1009  temp = os.path.abspath(temp)
1010  else:
1011  temp = os.path.dirname(name)
1012  if base.endswith('-lh') or base.endswith('.lh') or base.startswith('lh.'):
1013  if base.startswith('lh.'):
1014  base = base[3:]
1015  else:
1016  base = base[0:-3]
1017  if hemisphere == Hemisphere.Right:
1018  sys.stderr.write("Warning: Cortical surface file name suggests left hemisphere, but right hemisphere is being reconstructed!\n")
1019  elif hemisphere == Hemisphere.Unspecified:
1020  hemisphere = Hemisphere.Left
1021  elif base.endswith('-rh') or base.endswith('.rh') or base.startswith('rh.'):
1022  if base.startswith('rh.'):
1023  base = base[3:]
1024  else:
1025  base = base[0:-3]
1026  if hemisphere == Hemisphere.Left:
1027  sys.stderr.write("Warning: Cortical surface file name suggests right hemisphere, but left hemisphere is being reconstructed!\n")
1028  elif hemisphere == Hemisphere.Unspecified:
1029  hemisphere = Hemisphere.Right
1030  if not mask and hemisphere == Hemisphere.Both:
1031  rname = recon_cortical_surface('{}-{}{}'.format(base, hemi2str(Hemisphere.Right), ext),
1032  regions=regions, hemisphere=Hemisphere.Right,
1033  corpus_callosum_mask=corpus_callosum_mask, temp=temp)
1034  lname = recon_cortical_surface('{}-{}{}'.format(base, hemi2str(Hemisphere.Left), ext),
1035  regions=regions, hemisphere=Hemisphere.Left,
1036  corpus_callosum_mask=corpus_callosum_mask, temp=temp)
1037  return (rname, lname)
1038  base = os.path.basename(base)
1039  hemi = hemi2str(hemisphere)
1040  with ExitStack() as stack:
1041  if not mask:
1042  mask = '{}-{}-mask.nii.gz'.format(base, hemi)
1043  mask = push_output(stack, binarize_white_matter(regions, name=mask, hemisphere=hemisphere, temp=temp))
1044  dmap = push_output(stack, calculate_distance_map(mask, temp=temp))
1045  hull = push_output(stack, extract_convex_hull(dmap, temp=temp))
1046 
1047  opts = {
1048  'implicit-surface': dmap,
1049  'distance': 1,
1050  'distance-measure': 'normal',
1051  'distance-threshold': 2,
1052  'distance-max-depth': 5,
1053  'distance-hole-filling': True,
1054  'curvature': .8,
1055  'gauss-curvature': .4,
1056  'gauss-curvature-outside': 1,
1057  'gauss-curvature-minimum': .1,
1058  'gauss-curvature-maximum': .5,
1059  'repulsion': 4,
1060  'repulsion-distance': .5,
1061  'repulsion-width': 1,
1062  'optimizer': 'EulerMethod',
1063  'step': [.5, .1],
1064  'steps': [300, 200],
1065  'epsilon': 1e-8,
1066  'extrinsic-energy': True,
1067  'delta': 1e-2,
1068  'min-active': '1%',
1069  'min-width': .2,
1070  'min-distance': .5,
1071  'fast-collision-test': True,
1072  'non-self-intersection': True,
1073  'remesh': 1,
1074  'min-edge-length': .5,
1075  'max-edge-length': 1,
1076  'triangle-inversion': True,
1077  'reset-status': True
1078  }
1079 
1080  if corpus_callosum_mask:
1081  out1 = nextname(name, temp=temp)
1082  out1 = push_output(stack, add_corpus_callosum_mask(hull, mask=corpus_callosum_mask, oname=out1))
1083  out2 = nextname(out1)
1084  else:
1085  out1 = hull
1086  out2 = nextname(name, temp=temp)
1087  out2 = push_output(stack, deform_mesh(out1, out2, opts=opts))
1088  if corpus_callosum_mask:
1089  out3 = push_output(stack, del_corpus_callosum_mask(out2))
1090  else:
1091  out3 = out2
1092  remove_intersections(out3, oname=name, max_attempt=10)
1093  return name
1094 
1095 # ------------------------------------------------------------------------------
1096 def join_cortical_surfaces(name, regions, right_mesh, left_mesh, bs_cb_mesh=None,
1097  region_id_array=_region_id_array, cortex_mask_array=_cortex_mask_array,
1098  internal_mesh=None, temp=None, check=True):
1099  """Join cortical surfaces of right and left hemisphere at medial cut.
1100 
1101  Optionally, the brainstem plus cerebellum surface can be joined with the
1102  resulting surface mesh as well at the brainstem cut. This, however, currently
1103  fails for a significant number of cases to find a cutting plane which intersects
1104  the joined surface mesh with one closed curve near the superior brainstem cut
1105  and is therefore not recommended.
1106 
1107  Attention: Order of arguments may differ from the order of parameter help below!
1108  Pass parameter values as keyword argument, e.g., regions='regions.nii.gz'.
1109 
1110  Parameters
1111  ----------
1112  name : str
1113  Path of output surface mesh file.
1114  regions : str
1115  Path of regions image file with right and left cerebrum labelled.
1116  The boundary between right and left cerebrum segments defines the cut
1117  where the two surfaces are merged. Similarly for the brainstem cut
1118  when `bs_cb_mesh` surface given.
1119  right_mesh : str
1120  Path of right surface mesh file.
1121  left_mesh : str
1122  Path of left surface mesh file.
1123  bs_cb_mesh : str, optional
1124  Path of brainstem plus cerebellum surface mesh file.
1125  region_id_array : str, optional
1126  Name of cell data array with labels corresponding to the volumetric `regions`
1127  label of which the surface is the boundary of. Required for `cortex_mask_array`.
1128  Note: Required by subsequent cortical surface reconstruction steps.
1129  cortex_mask_array : str, optional
1130  Name of cortex mask cell data array. If None or an empty string,
1131  no cortex mask data array is added to the output surface file.
1132  Note: Required by subsequent cortical surface reconstruction steps.
1133  internal_mesh : str, optional
1134  Path of output surface mesh file with cutting plane cross sections
1135  that divide the interior of the merged surfaces into right, left,
1136  and brainstem plus cerebellum (when `bs_cb_mesh` surface given).
1137  temp : str
1138  Path of temporary working directory. Intermediate files are written to
1139  this directory and deleted on exit unless the global `debug` flag is set.
1140  When not specified, intermediate files are written to the same directory
1141  as the output mesh file, i.e., the directory of `name`.
1142  check : bool
1143  Check topology and consistency of output surface mesh.
1144 
1145  Returns
1146  -------
1147  path : str
1148  Absolute path of output surface mesh file.
1149 
1150  """
1151 
1152  if debug > 0:
1153  assert name, "Output file 'name' required"
1154  assert regions, "Input 'regions' label image required"
1155  assert right_mesh, "Input 'right_mesh' required"
1156  assert left_mesh, "Input 'left_mesh' required"
1157 
1158  join_bs_cb = True # deprecated option, always True when bs_cb_mesh not None
1159 
1160  name = os.path.abspath(name)
1161  if not temp:
1162  temp = os.path.dirname(name)
1163  else:
1164  temp = os.path.abspath(temp)
1165 
1166  if region_id_array:
1167  region_id_array_name = region_id_array
1168  else:
1169  region_id_array_name = _region_id_array
1170 
1171  with ExitStack() as stack:
1172  # merge surface meshes
1173  joined = push_output(stack, nextname(name, temp=temp))
1174  if force or not os.path.isfile(joined):
1175  surfaces = [right_mesh, left_mesh]
1176  if bs_cb_mesh and join_bs_cb:
1177  surfaces.append(bs_cb_mesh)
1178  run('merge-surfaces',
1179  opts={'input': surfaces, 'output': joined, 'labels': regions, 'source-array': region_id_array_name,
1180  'tolerance': 1, 'largest': True, 'dividers': (internal_mesh != None), 'snap-tolerance': .1,
1181  'smoothing-iterations': 100, 'smoothing-lambda': 1})
1182  if bs_cb_mesh and join_bs_cb:
1183  run('calculate-element-wise', args=[joined], opts=[('cell-data', region_id_array_name),
1184  ('map', (-1, -3), (-2, -1), (-3, -2), (3, 7)),
1185  ('out', joined)])
1186  del_mesh_attr(joined, pointdata=region_id_array_name)
1187  # check topology of joined surface mesh
1188  if check:
1189  info = evaluate_surface(joined, mesh=True, topology=True)
1190  num_boundaries = get_num_boundaries(info)
1191  if num_boundaries != 0:
1192  raise Exception('Merged surface is non-closed, no. of boundaries: {}'.format(num_boundaries))
1193  euler = get_euler_characteristic(info)
1194  if internal_mesh:
1195  if bs_cb_mesh: expect_euler = 4
1196  else: expect_euler = 3
1197  else: expect_euler = 2
1198  if euler != expect_euler:
1199  raise Exception('Merged surface with dividers has unexpected Euler characteristic: {} (expected {})'.format(euler, expect_euler))
1200  # ensure there are no self-intersections of the joined surface mesh
1201  checked = push_output(stack, nextname(joined))
1202  if force or not os.path.isfile(checked):
1203  remove_intersections(joined, checked, max_attempt=10, smooth_iter=10)
1204  joined = checked
1205  # when brainstem+cerebellum surface given, but it should not be joined with the
1206  # cerebrum surface, remove triangles near brainstem cut and any triangles of the
1207  # brainstem+cerebellum surface that intersect with the cerebrum surface
1208  if bs_cb_mesh and not join_bs_cb:
1209  joined_with_bscb = push_output(stack, nextname(joined))
1210  run('merge-surfaces',
1211  opts={'input': [joined, bs_cb_mesh], 'output': joined_with_bscb, 'labels': regions,
1212  'source-array': region_id_array_name, 'tolerance': .5, 'join': False})
1213  run('calculate-element-wise', args=[joined_with_bscb], opts=[('cell-data', region_id_array_name), ('map', (3, 7)), ('out', joined_with_bscb)])
1214  modified_bscb = push_output(stack, nextname(joined_with_bscb))
1215  check_intersections(joined_with_bscb, modified_bscb)
1216  run('calculate-element-wise', args=[modified_bscb],
1217  opts=[('cell-data', region_id_array_name), ('label', 7), ('set', 1), ('pad', 0),
1218  ('mul', 'CollisionType'), ('binarize', 0, 0), ('out', modified_bscb, 'binary', 'SelectionMask')])
1219  run('extract-pointset-cells', args=[modified_bscb, modified_bscb], opts=[('where', 'SelectionMask'), ('eq', 0)])
1220  del_mesh_attr(modified_bscb, pointdata='CollisionMask', celldata=['SelectionMask', 'CollisionType'])
1221  joined_with_modified_bscb = push_output(stack, nextname(modified_bscb))
1222  append_surfaces(joined_with_modified_bscb, surfaces=[joined, modified_bscb], merge=True, tol=0)
1223  joined = joined_with_modified_bscb
1224  # optionally, add cortex mask highlighting cells which are nearby cGM
1225  # this mask contains exactly two components, a right and a left cortex
1226  if cortex_mask_array:
1227  with output(binarize_cortex(regions, temp=temp), delete=True) as mask:
1228  joined = push_output(stack, add_cortex_mask(joined, mask, name=cortex_mask_array, region_id_array=region_id_array_name))
1229  if check:
1230  info = evaluate_surface(joined, mesh=True, opts=[('where', cortex_mask_array), ('gt', 0)])
1231  num_components = get_num_components(info)
1232  if num_components != 2:
1233  raise Exception("No. of cortex mask components: {} (expected 2)".format(num_components))
1234  # save divider(s) as separate surface mesh such that the inside of the
1235  # white surface is clearly defined when converting it to a binary mask
1236  # during the image edge-based refinement step below
1237  if internal_mesh:
1238  joined_without_dividers = push_output(stack, nextname(joined))
1239  internal_mesh = os.path.abspath(internal_mesh)
1240  run('extract-pointset-cells', args=[joined, internal_mesh], opts=[('where', region_id_array_name), ('lt', 0)])
1241  run('extract-pointset-cells', args=[joined, joined_without_dividers], opts=[('where', region_id_array_name), ('gt', 0)])
1242  joined = joined_without_dividers
1243  # remove RegionId array if not desired by caller
1244  if not region_id_array:
1245  if internal_mesh:
1246  del_mesh_attr(internal_mesh, celldata=region_id_array_name)
1247  del_mesh_attr(joined, celldata=region_id_array_name)
1248  rename(joined, name)
1249  if internal_mesh:
1250  return (name, internal_mesh)
1251  return name
1252 
1253 # ------------------------------------------------------------------------------
1254 def recon_white_surface(name, t2w_image, wm_mask, gm_mask, cortex_mesh, bs_cb_mesh=None,
1255  cortex_mask_array=_cortex_mask_array, region_id_array=_region_id_array,
1256  t1w_image=None, cortical_hull_dmap=None, segmentation=None,
1257  subcortex_labels =[], subcortex_mask =None,
1258  ventricles_labels=[], ventricles_mask=None, ventricles_dmap=None,
1259  cerebellum_labels=[], cerebellum_mask=None, cerebellum_dmap=None,
1260  temp=None, check=True):
1261  """Reconstruct white surface based on WM/cGM image edge distance forces.
1262 
1263  This step refines the initial surface mesh. In areas where the image
1264  edges are weak, no change of position should be enforced. Otherwise, move
1265  the surface points a limited distance from their initial position to a
1266  location along the normal direction where the image gradient is stronger.
1267  The resulting surface should delinate the WM/cGM interface at least as
1268  good as the initial surface mesh, but likely better in many places.
1269 
1270  Attention: Order of arguments may differ from the order of parameter help below!
1271  Pass parameter values as keyword argument, e.g., wm_mask='wm.nii.gz'.
1272 
1273  Parameters
1274  ----------
1275  name : str
1276  Path of output surface mesh file.
1277  cortex_mesh : str
1278  Path of initial cortical surface mesh file. This surface is reconstructed
1279  from a given brain segmentation with possible errors such as in particular
1280  CSF mislabelled as either WM or GM.
1281  bs_cb_mesh : str, optional
1282  Path of brainstem plus cerebellum mesh file.
1283  cortex_mask_array : str
1284  Name of cortex mask cell data array. Only nodes adjacent to only cortical
1285  faces are deformed. Non-cortical surface nodes remain unchanged.
1286 
1287  t1w_image : str, optional
1288  Path of T1-weighted intensity image file.
1289  t2w_image : str
1290  Path of T2-weighted intensity image file.
1291  wm_mask : str
1292  Path of binary WM segmentation image file.
1293  gm_mask : str
1294  Path of binary cGM segmentation image file.
1295 
1296  segmentation : str, optional
1297  Structural brain segmentation label image used to extract subcortical
1298  structures, ventricles, and cerebellum when structure labels instead
1299  of a pre-computed binary mask or structure distance image, respectively,
1300  is given. Otherwise, this image path is unused.
1301  subcortex_labels : int, list, optional
1302  Labels of `segmentation` labels corresponding to subcortical structures.
1303  Used to create a binary mask image when no `subcortex_mask` is given.
1304  subcortex_mask : str, optional
1305  Path of subcortical structures mask file. These structures are excluded
1306  from the image foreground such that the image-based edge distance
1307  force is not mislead by a WM/dGM edge of a subcortical structure.
1308  ventricles_labels : int, list, optional
1309  Labels of `segmentation` labels corresponding to lateral ventricles.
1310  Used to create a binary mask image when no `ventricles_mask` is given.
1311  ventricles_mask : str, optional
1312  Path of binary mask image file of lateral ventricles.
1313  ventricles_dmap : str, optional
1314  Path of distance map computed from lateral ventricles segment.
1315  cerebellum_labels : int, list, optional
1316  Labels of `segmentation` labels corresponding to cerebellum.
1317  Used to create a binary mask image when no `cerebellum_mask` is given.
1318  cerebellum_mask : str, optional
1319  Path of binary mask image file of cerebellum.
1320  cerebellum_dmap : str, optional
1321  Path of distance map computed from cerebellum segment.
1322  cortical_hull_dmap : str, optional
1323  Path of distance image from each voxel to the cortical hull with
1324  positive values inside the cortical hull. This image is an optional
1325  output of the `subdivide-brain-image` tool.
1326 
1327  temp : str
1328  Path of temporary working directory. Intermediate files are written to
1329  this directory and deleted on exit unless the global `debug` flag is set.
1330  When not specified, intermediate files are written to the same directory
1331  as the output mesh file, i.e., the directory of `name`.
1332  check : bool
1333  Check final surface mesh for self-intersections and try to remove these.
1334 
1335  Returns
1336  -------
1337  path : str
1338  Absolute path of output surface mesh file.
1339 
1340  """
1341 
1342  if debug > 0:
1343  assert name, "Output file 'name' required"
1344  assert t2w_image, "T2-weighted intensity image required"
1345  assert wm_mask, "White matter segmentation mask required"
1346  assert gm_mask, "Gray matter segmentation mask required"
1347  assert cortex_mesh, "Initial cortical surface mesh required"
1348 
1349  name = os.path.abspath(name)
1350  (base, ext) = splitext(name)
1351  if not ext:
1352  ext = '.vtp'
1353  name = base + ext
1354  if not temp:
1355  temp = os.path.dirname(name)
1356  else:
1357  temp = os.path.abspath(temp)
1358 
1359  with ExitStack() as stack:
1360 
1361  init_mesh = push_output(stack, nextname(name, temp=temp))
1362 
1363  # optionally, append brainstem plus cerebellum
1364  if bs_cb_mesh:
1365  append_surfaces(init_mesh, surfaces=[cortex_mesh, bs_cb_mesh], merge=False)
1366  cortex_mesh = init_mesh
1367 
1368  # initialize node status
1369  status_array = 'InitialStatus'
1370  run('copy-pointset-attributes', args=[cortex_mesh, cortex_mesh, init_mesh],
1371  opts={'celldata-as-pointdata': [cortex_mask_array, status_array, 'other', 'binary'], 'unanimous': None})
1372  run('erode-scalars', args=[init_mesh, init_mesh], opts={'array': status_array, 'iterations': 8})
1373 
1374  # deform surface towards WM/cGM image edges
1375  opts={'image': t2w_image,
1376  'wm-mask': wm_mask,
1377  'gm-mask': gm_mask,
1378  'edge-distance': 1,
1379  'edge-distance-type': 'Neonatal T2-w WM/cGM',
1380  'edge-distance-max-depth': 5,
1381  'edge-distance-median': 1,
1382  'edge-distance-smoothing': 1,
1383  'edge-distance-averaging': [4, 2, 1],
1384  'curvature': 2,
1385  'gauss-curvature': .5,
1386  'gauss-curvature-outside': .5,
1387  'gauss-curvature-minimum': .1,
1388  'gauss-curvature-maximum': .2,
1389  'optimizer': 'EulerMethod',
1390  'step': .2,
1391  'steps': [50, 100],
1392  'extrinsic-energy': True,
1393  'epsilon': 1e-6,
1394  'delta': .01,
1395  'min-active': '1%',
1396  'reset-status': True,
1397  'non-self-intersection': True,
1398  'fast-collision-test': True,
1399  'min-width': .1,
1400  'repulsion': 4,
1401  'repulsion-distance': .5,
1402  'repulsion-width': 1,
1403  'remesh': 1,
1404  'min-edge-length': .5,
1405  'max-edge-length': 1}
1406  if t1w_image:
1407  opts['t1w-image'] = t1w_image
1408  if cortical_hull_dmap:
1409  opts['inner-cortical-distance-image'] = cortical_hull_dmap
1410  if (segmentation and ventricles_labels) or ventricles_mask or ventricles_dmap:
1411  if not ventricles_dmap:
1412  if not ventricles_mask:
1413  ventricles_mask = os.path.join(temp, 'ventricles-mask.nii.gz')
1414  ventricles_mask = push_output(stack, binarize(name=ventricles_mask, segmentation=segmentation, labels=ventricles_labels))
1415  ventricles_dmap = push_output(stack, calculate_distance_map(ventricles_mask, temp=temp))
1416  opts['ventricles-distance-image'] = ventricles_dmap
1417  if (segmentation and cerebellum_labels) or cerebellum_mask or cerebellum_dmap:
1418  if not cerebellum_dmap:
1419  if not cerebellum_mask:
1420  cerebellum_mask = os.path.join(temp, 'cerebellum-mask.nii.gz')
1421  cerebellum_mask = push_output(stack, binarize(name=cerebellum_mask, segmentation=segmentation, labels=cerebellum_labels))
1422  cerebellum_dmap = push_output(stack, calculate_distance_map(cerebellum_mask, temp=temp))
1423  opts['cerebellum-distance-image'] = cerebellum_dmap
1424  if (segmentation and subcortex_labels) or subcortex_mask:
1425  if not subcortex_mask:
1426  subcortex_mask = os.path.join(temp, 'subcortex-mask.nii.gz')
1427  subcortex_mask = push_output(stack, binarize(name=subcortex_mask, segmentation=segmentation, labels=subcortex_labels))
1428  mask = os.path.join(temp, os.path.basename(base) + '-foreground.nii.gz')
1429  opts['mask'] = push_output(stack, white_refinement_mask(mask, subcortex_mask))
1430  mesh = push_output(stack, deform_mesh(init_mesh, opts=opts))
1431  if bs_cb_mesh:
1432  run('extract-pointset-cells', args=[mesh, mesh], opts=[('where', region_id_array), ('ne', 7)])
1433 
1434  # smooth white surface mesh
1435  smooth = push_output(stack, smooth_surface(mesh, iterations=100, lambda_value=.33, mu=-.34, weighting='combinatorial', excl_node=True, mask=status_array))
1436 
1437  # remove intersections if any
1438  if check:
1439  remove_intersections(smooth, oname=name, mask=status_array)
1440 
1441  # write output mesh
1442  del_mesh_attr(smooth, pointdata=status_array)
1443  rename(smooth, name)
1444 
1445  return name
1446 
1447 # ------------------------------------------------------------------------------
1448 def recon_pial_surface(name, t2w_image, wm_mask, gm_mask, white_mesh,
1449  bs_cb_mesh=None, brain_mask=None, remesh=0, outside_white_mesh=True,
1450  region_id_array=_region_id_array, cortex_mask_array=_cortex_mask_array,
1451  temp=None, check=True):
1452  """Reconstruct pial surface based on cGM/CSF image edge distance forces.
1453 
1454  When the pial surface is not allowed to intersect the white surface mesh
1455  (i.e., `outside_white_surface=True`), the following steps are performed:
1456  1. Deform cortical nodes outside up to a maximum distance from the white surface
1457  - Hard non-self-intersection constraint disabled
1458  2. Blend between initial and outside white surface node position
1459  3. Merge white and pial surfaces into single connected surface mesh
1460  - If white and pial surfaces intersect afterwards, try to
1461  remove those by smoothing the surface near the intersections
1462  4. Deform pial surface nodes towards cGM/CSF image edges
1463  - Hard non-self-intersection constraint enabled
1464  5. Remove white surface mesh from output mesh file
1465 
1466  Otherwise, only step 4 is performed to deform the input white surface mesh
1467  towards the outer cortical surface based on image edge forces. In this case,
1468  the resulting pial surface mesh may potentially intersect the `white_mesh`.
1469 
1470  Attention: Order of arguments may differ from the order of parameter help below!
1471  Pass parameter values as keyword argument, e.g., wm_mask='wm.nii.gz'.
1472 
1473  Parameters
1474  ----------
1475  name : str
1476  Path of output surface mesh file.
1477  t2w_image : str
1478  Path of T2-weighted intensity image file.
1479  wm_mask : str
1480  Path of binary WM segmentation image file.
1481  gm_mask : str
1482  Path of binary cGM segmentation image file.
1483  white_mesh : str
1484  Path of white surface mesh file.
1485  bs_cb_mesh : str, optional
1486  Path of brainstem plus cerebellum surface mesh file. If specified,
1487  this surface mesh is added to the initial pial surface mesh for step 4
1488  of the pial surface reconstruction such that the pial surface does
1489  not deform into the brainstem plus cerebellum region.
1490  remesh : int
1491  Number of Euler steps between local remeshing is performed.
1492  Generally, it is better to not remesh (`remesh=0`) such that ID based
1493  one-to-one vertex correspondences between white and pial surface meshes
1494  are preserved. These vertex correspondences simplify further analysis.
1495  outside_white_mesh : bool
1496  Whether pial surface mesh is required to be strictly outside the white
1497  surface mesh, i.e., the pial surface may not intersect the `white_mesh`.
1498  This is recommended, but may currently fail for some cases when the
1499  blended offset surface intersects the white surface mesh after step 3.
1500  brain_mask : str, optional
1501  Path of brain extraction mask file.
1502  temp : str, optional
1503  Path of temporary working directory. Intermediate files are written to
1504  this directory and deleted on exit unless the global `debug` flag is set.
1505  When not specified, intermediate files are written to the same directory
1506  as the output mesh file, i.e., the directory of `name`.
1507  check : bool
1508  Check topology and consistency of intermediate surface meshes.
1509 
1510  Returns
1511  -------
1512  path : str
1513  Absolute path of output surface mesh file.
1514 
1515  """
1516 
1517  if debug > 0:
1518  assert name, "Output file 'name' required"
1519  assert t2w_image, "T2-weighted intensity image required"
1520  assert wm_mask, "White matter segmentation mask required"
1521  assert gm_mask, "Gray matter segmentation mask required"
1522  assert white_mesh, "White surface mesh required"
1523 
1524  name = os.path.abspath(name)
1525  (base, ext) = splitext(name)
1526  if not ext:
1527  ext = '.vtp'
1528  name = base + ext
1529  if not temp:
1530  temp = os.path.dirname(name)
1531  else:
1532  temp = os.path.abspath(temp)
1533 
1534  with ExitStack() as stack:
1535 
1536  # create foreground mask
1537  mask = push_output(stack, os.path.join(temp, os.path.basename(base) + '-foreground.nii.gz'))
1538  run('extract-pointset-surface', args=[], opts={'input': white_mesh, 'mask': mask, 'reference': t2w_image, 'outside': True})
1539  if brain_mask:
1540  run('calculate-element-wise', args=[mask], opts=[('mul', brain_mask), ('out', mask)])
1541 
1542  # initialize deformable surface mesh
1543  init_mesh = push_output(stack, nextname(name, temp=temp))
1544  status_array = 'Status'
1545  if outside_white_mesh:
1546 
1547  # initialize node status
1548  run('copy-pointset-attributes', args=[white_mesh, white_mesh, init_mesh], opts={'celldata-as-pointdata': [region_id_array, status_array], 'unanimous': None})
1549  run('calculate-element-wise', args=[init_mesh], opts=[('point-data', status_array), ('label', 7), ('set', 0), ('pad', 1), ('out', init_mesh, 'binary')])
1550 
1551  # deform pial surface outwards a few millimeters
1552  opts={'normal-force': 1,
1553  'curvature': 1,
1554  'optimizer': 'EulerMethod',
1555  'step': .1,
1556  'steps': 100,
1557  'max-displacement': max(get_voxel_size(t2w_image)),
1558  'non-self-intersection': True,
1559  'fast-collision-test': True,
1560  'min-distance': .1,
1561  'min-active': '10%',
1562  'delta': .0001}
1563  offset_mesh = push_output(stack, deform_mesh(init_mesh, opts=opts))
1564  if debug == 0: try_remove(init_mesh)
1565 
1566  # blend between original position of non-cortical points and offset surface points
1567  blended_mesh = push_output(stack, nextname(offset_mesh))
1568  run('copy-pointset-attributes', args=[offset_mesh, offset_mesh, blended_mesh], opts={'celldata-as-pointdata': cortex_mask_array, 'unanimous': None})
1569  run('blend-surface', args=[white_mesh, blended_mesh, blended_mesh], opts={'where': cortex_mask_array, 'gt': 0, 'smooth-iterations': 3})
1570  run('calculate-element-wise', args=[blended_mesh], opts=[('cell-data', region_id_array), ('map', (1, 3), (2, 4)), ('out', blended_mesh)])
1571  if debug == 0: try_remove(offset_mesh)
1572 
1573  # merge white surface mesh with initial pial surface mesh
1574  cortex_mesh = push_output(stack, nextname(blended_mesh))
1575  append_surfaces(cortex_mesh, surfaces=[white_mesh, blended_mesh], merge=True, tol=0)
1576  info = evaluate_surface(cortex_mesh, cortex_mesh, mesh=True, opts=[('where', cortex_mask_array), ('gt', 0)])
1577  run('calculate-element-wise', args=[cortex_mesh], opts=[('cell-data', cortex_mask_array), ('mask', 'BoundaryMask'), ('set', 0), ('out', cortex_mesh)])
1578  run('calculate-element-wise', args=[cortex_mesh], opts=[('cell-data', region_id_array), ('mask', 'BoundaryMask'), ('add', 4), ('out', cortex_mesh)])
1579  num_components = get_num_components(info)
1580  if num_components < 2:
1581  raise Exception("No. of cortex mask components: {} (expected 2)".format(num_components))
1582  elif num_components > 2:
1583  run('calculate-element-wise', args=[cortex_mesh],
1584  opts=[('cell-data', 'ComponentId'), ('threshold-gt', 2), ('pad', 0), ('mul', cortex_mask_array),
1585  ('clamp-above', 1), ('out', cortex_mesh, 'binary', cortex_mask_array)])
1586  del_mesh_attr(cortex_mesh, name=['ComponentId', 'BoundaryMask'], celldata='DuplicatedMask')
1587  if debug == 0: try_remove(blended_mesh)
1588 
1589  # resolve intersections between white and pial surface if any
1590  init_mesh = push_output(stack, nextname(cortex_mesh))
1591  remove_white_pial_intersections(cortex_mesh, init_mesh, region_id_array=region_id_array)
1592  #run('copy-pointset-attributes', args=[cortex_mesh, cortex_mesh, init_mesh], opts={'celldata-as-pointdata': cortex_mask_array, 'unanimous': None})
1593  #remove_intersections(init_mesh, init_mesh, mask=cortex_mask_array)
1594  #del_mesh_attr(init_mesh, pointdata=cortex_mask_array)
1595  if debug == 0: try_remove(cortex_mesh)
1596  else:
1597  run('calculate-element-wise', args=[white_mesh], opts=[('cell-data', region_id_array), ('map', (1, 3), (2, 4)), ('out', init_mesh)])
1598 
1599  # optionally, append brainstem plus cerebellum surface
1600  if bs_cb_mesh:
1601  append_surfaces(init_mesh, surfaces=[init_mesh, bs_cb_mesh], merge=False)
1602 
1603  # initialize node status for pial surface reconstruction
1604  run('copy-pointset-attributes', args=[init_mesh, init_mesh], opts={'celldata-as-pointdata': [region_id_array, status_array], 'unanimous': None})
1605  run('calculate-element-wise', args=[init_mesh], opts=[('point-data', status_array), ('label', 3, 4), ('set', 1), ('pad', 0), ('out', init_mesh, 'binary')])
1606 
1607  # deform pial surface towards cGM/CSF image edges
1608  opts={'image': t2w_image,
1609  'mask': mask,
1610  'wm-mask': wm_mask,
1611  'gm-mask': gm_mask,
1612  'edge-distance': 1,
1613  'edge-distance-type': 'Neonatal T2-w cGM/CSF',
1614  'edge-distance-max-depth': 5,
1615  'edge-distance-median': 1,
1616  'edge-distance-smoothing': 1,
1617  'edge-distance-averaging': [4, 2, 1],
1618  'curvature': 2,
1619  'gauss-curvature': .8,
1620  'gauss-curvature-inside': 2,
1621  'gauss-curvature-minimum': .1,
1622  'gauss-curvature-maximum': .4,
1623  'negative-gauss-curvature-action': 'inflate',
1624  'repulsion': 2,
1625  'repulsion-distance': .5,
1626  'repulsion-width': 1,
1627  'optimizer': 'EulerMethod',
1628  'step': .2,
1629  'steps': [25, 50, 100],
1630  'epsilon': 1e-6,
1631  'delta': .01,
1632  'min-active': '5%',
1633  'reset-status': True,
1634  'non-self-intersection': True,
1635  'fast-collision-test': True,
1636  'min-distance': .1,
1637  'remesh': remesh,
1638  'min-edge-length': .3,
1639  'max-edge-length': 2,
1640  # Inversion may cause edges at the bottom of sulci to change from running
1641  # along the sulcus (minimum curvature direction) to be inverted to then
1642  # go across the sulcus (maximum curvature direction) which iteratively
1643  # contributes to smoothing out the sulci which should actually be preserved.
1644  'triangle-inversion': False}
1645  mesh = push_output(stack, deform_mesh(init_mesh, opts=opts))
1646  extract_surface(mesh, name, array=region_id_array, labels=[-1, -2, -3, 3, 4, 5, 6])
1647 
1648  return name
1649 
1650 # ------------------------------------------------------------------------------
1651 def split_cortical_surfaces(joined_mesh, right_name=None, left_name=None, internal_mesh=None, temp=None):
1652  """Save cortical surfaces of right and left hemispheres as separate meshes."""
1653  # TODO: If no `internal_mesh` mesh is avaiable, use vtkFillHolesFilter
1654  # to close the non-closed genus-0 surfaces at the medial or brainstem cut.
1655  # Possibly ensure that the new cells/points are identical for
1656  # the closed surface meshes of right and left hemisphere.
1657  #
1658  # See extract-pointset-surface -fill-holes
1659  if debug > 0:
1660  assert joined_mesh, "Joined cortical surface mesh required"
1661  assert right_name or left_name, "At least either 'right_name' or 'left_name' required"
1662  if not temp:
1663  temp = os.path.dirname(joined_mesh)
1664  with ExitStack() as stack:
1665  if internal_mesh:
1666  name = os.path.join(temp, os.path.splitext(joined_mesh)[0] + '+internal.vtp')
1667  joined_mesh = push_output(stack, append_surfaces(name, surfaces=[joined_mesh, internal_mesh], merge=True, tol=0))
1668  if right_name: extract_surface(joined_mesh, right_name, labels=[-1, -3, 1, 3, 5])
1669  if left_name: extract_surface(joined_mesh, left_name, labels=[-1, -2, 2, 4, 6])