__init__.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. """
  2. Python Library to Read and Write Surface Files in Freesurfer's TriangularSurface Format
  3. compatible with Freesurfer's MRISwriteTriangularSurface()
  4. https://github.com/freesurfer/freesurfer/blob/release_6_0_0/include/mrisurf.h#L1281
  5. https://github.com/freesurfer/freesurfer/blob/release_6_0_0/utils/mrisurf.c
  6. https://raw.githubusercontent.com/freesurfer/freesurfer/release_6_0_0/utils/mrisurf.c
  7. Freesurfer
  8. https://surfer.nmr.mgh.harvard.edu/
  9. >>> from freesurfer_surface import Surface, Vertex
  10. >>>
  11. >>> surface = Surface.read_triangular('bert/surf/lh.pial'))
  12. >>>
  13. >>> vertex_index = surface.add_vertex(Vertex(0.0, -3.14, 21.42))
  14. >>> print(surface.vertices[vertex_index])
  15. >>> surface.write_triangular('somewhere/else/lh.pial')
  16. >>>
  17. >>> surface.load_annotation_file('bert/label/lh.aparc.annot')
  18. >>> print([label.name for label in surface.annotation.labels])
  19. >>>
  20. >>> precentral, = filter(lambda l: l.name == 'precentral', annotation.labels.values())
  21. >>> print(precentral.hex_color_code)
  22. >>>
  23. >>> precentral_vertix_indices = [vertex_index for vertex_index, label_index
  24. >>> in surface.annotation.vertex_label_index.items()
  25. >>> if label_index == precentral.index]
  26. >>> print(len(precentral_vertix_indices))
  27. """
  28. import collections
  29. import contextlib
  30. import datetime
  31. import itertools
  32. import locale
  33. import re
  34. import struct
  35. import typing
  36. import numpy
  37. try:
  38. from freesurfer_surface.version import __version__
  39. except ImportError: # pragma: no cover
  40. __version__ = None
  41. class UnsupportedLocaleSettingError(locale.Error):
  42. pass
  43. @contextlib.contextmanager
  44. def setlocale(temporary_locale):
  45. primary_locale = locale.setlocale(locale.LC_ALL)
  46. try:
  47. yield locale.setlocale(locale.LC_ALL, temporary_locale)
  48. except locale.Error as exc:
  49. if str(exc) == 'unsupported locale setting':
  50. raise UnsupportedLocaleSettingError(temporary_locale)
  51. raise exc
  52. finally:
  53. locale.setlocale(locale.LC_ALL, primary_locale)
  54. Vertex = collections.namedtuple('Vertex', ['right', 'anterior', 'superior'])
  55. class _PolygonalCircuit:
  56. _VERTEX_INDICES_TYPE = typing.Tuple[int]
  57. def __init__(self, vertex_indices: _VERTEX_INDICES_TYPE):
  58. self.vertex_indices: self._VERTEX_INDICES_TYPE = vertex_indices
  59. @property
  60. def vertex_indices(self):
  61. return self._vertex_indices
  62. @vertex_indices.setter
  63. def vertex_indices(self, indices: _VERTEX_INDICES_TYPE):
  64. # pylint: disable=attribute-defined-outside-init
  65. self._vertex_indices = tuple(indices)
  66. def _normalize(self) -> '_PolygonalCircuit':
  67. min_vertex_index_index = self.vertex_indices.index(min(self.vertex_indices))
  68. return type(self)(self.vertex_indices[min_vertex_index_index:]
  69. + self.vertex_indices[:min_vertex_index_index])
  70. def __eq__(self, other: '_PolygonalCircuit') -> bool:
  71. # pylint: disable=protected-access
  72. return self._normalize().vertex_indices == other._normalize().vertex_indices
  73. def __hash__(self) -> int:
  74. # pylint: disable=protected-access
  75. return hash(self._normalize()._vertex_indices)
  76. class _LineSegment(_PolygonalCircuit):
  77. # pylint: disable=no-member
  78. @_PolygonalCircuit.vertex_indices.setter
  79. def vertex_indices(self, indices: _PolygonalCircuit._VERTEX_INDICES_TYPE):
  80. assert len(indices) == 2
  81. # pylint: disable=attribute-defined-outside-init
  82. self._vertex_indices = tuple(indices)
  83. def __repr__(self) -> str:
  84. return '_LineSegment(vertex_indices={})'.format(self.vertex_indices)
  85. class Triangle(_PolygonalCircuit):
  86. # pylint: disable=no-member
  87. @_PolygonalCircuit.vertex_indices.setter
  88. def vertex_indices(self, indices: _PolygonalCircuit._VERTEX_INDICES_TYPE):
  89. assert len(indices) == 3
  90. # pylint: disable=attribute-defined-outside-init
  91. self._vertex_indices = tuple(indices)
  92. def __repr__(self) -> str:
  93. return 'Triangle(vertex_indices={})'.format(self.vertex_indices)
  94. class PolygonalChainsNotOverlapingError(ValueError):
  95. pass
  96. class PolygonalChain:
  97. def __init__(self, vertex_indices: typing.Iterable[int]):
  98. self.vertex_indices: typing.Deque[int] = collections.deque(vertex_indices)
  99. def __eq__(self, other: 'PolygonalChain') -> bool:
  100. return self.vertex_indices == other.vertex_indices
  101. def __repr__(self) -> str:
  102. return 'PolygonalChain(vertex_indices={})'.format(tuple(self.vertex_indices))
  103. def connect(self, other: 'PolygonalChain') -> None:
  104. if self.vertex_indices[-1] == other.vertex_indices[0]:
  105. self.vertex_indices.pop()
  106. self.vertex_indices.extend(other.vertex_indices)
  107. elif self.vertex_indices[-1] == other.vertex_indices[-1]:
  108. self.vertex_indices.pop()
  109. self.vertex_indices.extend(reversed(other.vertex_indices))
  110. elif self.vertex_indices[0] == other.vertex_indices[0]:
  111. self.vertex_indices.popleft()
  112. self.vertex_indices.extendleft(other.vertex_indices)
  113. elif self.vertex_indices[0] == other.vertex_indices[-1]:
  114. self.vertex_indices.popleft()
  115. self.vertex_indices.extendleft(reversed(other.vertex_indices))
  116. else:
  117. raise PolygonalChainsNotOverlapingError()
  118. def segments(self) -> typing.Iterable[_LineSegment]:
  119. indices = self.vertex_indices
  120. return map(_LineSegment, zip(indices, itertools.islice(indices, 1, len(indices))))
  121. class Label:
  122. # pylint: disable=too-many-arguments
  123. def __init__(self, index: int, name: str, red: int,
  124. green: int, blue: int, transparency: int):
  125. self.index: int = index
  126. self.name: str = name
  127. self.red: int = red
  128. self.green: int = green
  129. self.blue: int = blue
  130. self.transparency: int = transparency
  131. @property
  132. def color_code(self) -> int:
  133. if self.index == 0: # unknown
  134. return 0
  135. return int.from_bytes((self.red, self.green, self.blue, self.transparency),
  136. byteorder='little', signed=False)
  137. @property
  138. def hex_color_code(self) -> str:
  139. return '#{:02x}{:02x}{:02x}'.format(self.red, self.green, self.blue)
  140. def __str__(self) -> str:
  141. return 'Label(name={}, index={}, color={})'.format(
  142. self.name, self.index, self.hex_color_code)
  143. def __repr__(self) -> str:
  144. return str(self)
  145. class Annotation:
  146. # pylint: disable=too-few-public-methods
  147. _TAG_OLD_COLORTABLE = b'\0\0\0\x01'
  148. def __init__(self):
  149. self.vertex_label_index: typing.Dict[int, int] = {}
  150. self.colortable_path: typing.Optional[bytes] = None
  151. self.labels: typing.Dict[int, Label] = {}
  152. @staticmethod
  153. def _read_label(stream: typing.BinaryIO) -> Label:
  154. index, name_length = struct.unpack('>II', stream.read(4 * 2))
  155. name = stream.read(name_length - 1).decode()
  156. assert stream.read(1) == b'\0'
  157. red, green, blue, transparency = struct.unpack('>IIII', stream.read(4 * 4))
  158. return Label(index=index, name=name, red=red, green=green,
  159. blue=blue, transparency=transparency)
  160. def _read(self, stream: typing.BinaryIO) -> None:
  161. # https://surfer.nmr.mgh.harvard.edu/fswiki/LabelsClutsAnnotationFiles
  162. annotations_num, = struct.unpack('>I', stream.read(4))
  163. annotations = [struct.unpack('>II', stream.read(4 * 2))
  164. for _ in range(annotations_num)]
  165. assert stream.read(4) == self._TAG_OLD_COLORTABLE
  166. colortable_version, _, filename_length = struct.unpack('>III', stream.read(4 * 3))
  167. assert colortable_version > 0 # new version
  168. self.colortable_path = stream.read(filename_length - 1)
  169. assert stream.read(1) == b'\0'
  170. labels_num, = struct.unpack('>I', stream.read(4))
  171. self.labels = {label.index: label for label
  172. in (self._read_label(stream) for _ in range(labels_num))}
  173. label_index_by_color_code = {label.color_code: label.index
  174. for label in self.labels.values()}
  175. self.vertex_label_index = {vertex_index: label_index_by_color_code[color_code]
  176. for vertex_index, color_code in annotations}
  177. assert not stream.read(1)
  178. @classmethod
  179. def read(cls, annotation_file_path: str) -> 'Annotation':
  180. annotation = cls()
  181. with open(annotation_file_path, 'rb') as annotation_file:
  182. # pylint: disable=protected-access
  183. annotation._read(annotation_file)
  184. return annotation
  185. class Surface:
  186. # pylint: disable=too-many-instance-attributes
  187. _MAGIC_NUMBER = b'\xff\xff\xfe'
  188. _TAG_CMDLINE = b'\x00\x00\x00\x03'
  189. _TAG_OLD_SURF_GEOM = b'\x00\x00\x00\x14'
  190. _TAG_OLD_USEREALRAS = b'\x00\x00\x00\x02'
  191. _DATETIME_FORMAT = '%a %b %d %H:%M:%S %Y'
  192. def __init__(self):
  193. self.creator: typing.Optional[bytes] = None
  194. self.creation_datetime: typing.Optional[datetime.datetime] = None
  195. self.vertices: typing.List[Vertex] = []
  196. self.triangles: typing.List[Triangle] = []
  197. self.using_old_real_ras: bool = False
  198. self.volume_geometry_info: typing.Optional[typing.Tuple[bytes]] = None
  199. self.command_lines: typing.List[bytes] = []
  200. self.annotation: typing.Optional[Annotation] = None
  201. @classmethod
  202. def _read_cmdlines(cls, stream: typing.BinaryIO) -> typing.Iterator[str]:
  203. while True:
  204. tag = stream.read(4)
  205. if not tag:
  206. return
  207. assert tag == cls._TAG_CMDLINE # might be TAG_GROUP_AVG_SURFACE_AREA
  208. # TAGwrite
  209. # https://github.com/freesurfer/freesurfer/blob/release_6_0_0/utils/tags.c#L94
  210. str_length, = struct.unpack('>Q', stream.read(8))
  211. yield stream.read(str_length - 1)
  212. assert stream.read(1) == b'\x00'
  213. def _read_triangular(self, stream: typing.BinaryIO):
  214. assert stream.read(3) == self._MAGIC_NUMBER
  215. self.creator, creation_dt_str = re.match(rb'^created by (\w+) on (.* \d{4})\n',
  216. stream.readline()).groups()
  217. with setlocale('C'):
  218. self.creation_datetime = datetime.datetime.strptime(creation_dt_str.decode(),
  219. self._DATETIME_FORMAT)
  220. assert stream.read(1) == b'\n'
  221. # fwriteInt
  222. # https://github.com/freesurfer/freesurfer/blob/release_6_0_0/utils/fio.c#L290
  223. vertices_num, triangles_num = struct.unpack('>II', stream.read(4 * 2))
  224. self.vertices = [Vertex(*struct.unpack('>fff', stream.read(4 * 3)))
  225. for _ in range(vertices_num)]
  226. self.triangles = [Triangle(struct.unpack('>III', stream.read(4 * 3)))
  227. for _ in range(triangles_num)]
  228. assert all(vertex_idx < vertices_num
  229. for triangle in self.triangles
  230. for vertex_idx in triangle.vertex_indices)
  231. assert stream.read(4) == self._TAG_OLD_USEREALRAS
  232. using_old_real_ras, = struct.unpack('>I', stream.read(4))
  233. assert using_old_real_ras in [0, 1], using_old_real_ras
  234. self.using_old_real_ras = bool(using_old_real_ras)
  235. assert stream.read(4) == self._TAG_OLD_SURF_GEOM
  236. # writeVolGeom
  237. # https://github.com/freesurfer/freesurfer/blob/release_6_0_0/utils/transform.c#L368
  238. self.volume_geometry_info = tuple(stream.readline() for _ in range(8))
  239. self.command_lines = list(self._read_cmdlines(stream))
  240. @classmethod
  241. def read_triangular(cls, surface_file_path: str) -> 'Surface':
  242. surface = cls()
  243. with open(surface_file_path, 'rb') as surface_file:
  244. # pylint: disable=protected-access
  245. surface._read_triangular(surface_file)
  246. return surface
  247. def _triangular_creation_datetime_strftime(self) -> bytes:
  248. fmt = self._DATETIME_FORMAT.replace('%d', '{:>2}'.format(self.creation_datetime.day))
  249. with setlocale('C'):
  250. return self.creation_datetime.strftime(fmt).encode()
  251. def write_triangular(self, surface_file_path: str,
  252. creation_datetime: typing.Optional[datetime.datetime] = None):
  253. if creation_datetime is None:
  254. self.creation_datetime = datetime.datetime.now()
  255. else:
  256. self.creation_datetime = creation_datetime
  257. with open(surface_file_path, 'wb') as surface_file:
  258. surface_file.write(
  259. self._MAGIC_NUMBER
  260. + b'created by ' + self.creator
  261. + b' on ' + self._triangular_creation_datetime_strftime()
  262. + b'\n\n'
  263. + struct.pack('>II', len(self.vertices), len(self.triangles))
  264. )
  265. for vertex in self.vertices:
  266. surface_file.write(struct.pack('>fff', *vertex))
  267. for triangle in self.triangles:
  268. surface_file.write(struct.pack('>III', *triangle.vertex_indices))
  269. surface_file.write(self._TAG_OLD_USEREALRAS
  270. + struct.pack('>I', 1 if self.using_old_real_ras else 0))
  271. surface_file.write(self._TAG_OLD_SURF_GEOM
  272. + b''.join(self.volume_geometry_info))
  273. for command_line in self.command_lines:
  274. surface_file.write(self._TAG_CMDLINE + struct.pack('>Q', len(command_line) + 1)
  275. + command_line + b'\0')
  276. def load_annotation_file(self, annotation_file_path: str) -> None:
  277. annotation = Annotation.read(annotation_file_path)
  278. assert len(annotation.vertex_label_index) <= len(self.vertices)
  279. assert max(annotation.vertex_label_index.keys()) < len(self.vertices)
  280. self.annotation = annotation
  281. def add_vertex(self, vertex: Vertex) -> int:
  282. self.vertices.append(vertex)
  283. return len(self.vertices) - 1
  284. def add_rectangle(self, vertex_indices: typing.Iterable[int]) -> typing.Iterable[int]:
  285. vertex_indices = list(vertex_indices)
  286. assert len(vertex_indices) == 3
  287. vertex_coords = [numpy.array(self.vertices[vertex_index])
  288. for vertex_index in vertex_indices]
  289. vertex_coords.append(vertex_coords[0] + vertex_coords[2] - vertex_coords[1])
  290. vertex_indices.append(self.add_vertex(Vertex(*vertex_coords[3])))
  291. self.triangles.append(Triangle(vertex_indices[:3]))
  292. self.triangles.append(Triangle(vertex_indices[2:] + vertex_indices[:1]))
  293. def _find_label_border_segments(self, label: Label) -> typing.Iterator[_LineSegment]:
  294. for triangle in self.triangles:
  295. border_vertex_indices = tuple(filter(
  296. lambda i: self.annotation.vertex_label_index[i] == label.index,
  297. triangle.vertex_indices,
  298. ))
  299. if len(border_vertex_indices) == 2:
  300. yield _LineSegment(border_vertex_indices)
  301. def find_label_border_polygonal_chains(self, label: Label) -> typing.Iterator[PolygonalChain]:
  302. segments = set(self._find_label_border_segments(label))
  303. available_chains = collections.deque(PolygonalChain(segment.vertex_indices)
  304. for segment in segments)
  305. # irrespective of its poor performance,
  306. # we keep this approach since it's easy to read and fast enough
  307. while available_chains:
  308. chain = available_chains.pop()
  309. last_chains_len = None
  310. while last_chains_len != len(available_chains):
  311. last_chains_len = len(available_chains)
  312. checked_chains = collections.deque()
  313. while available_chains:
  314. potential_neighbour = available_chains.pop()
  315. try:
  316. chain.connect(potential_neighbour)
  317. except PolygonalChainsNotOverlapingError:
  318. checked_chains.append(potential_neighbour)
  319. available_chains = checked_chains
  320. assert all((segment in segments) for segment in chain.segments())
  321. yield chain