phantom.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597
  1. # Copyright of the Board of Trustees of Columbia University in the City of New York
  2. """
  3. Numerical phantom generation and access
  4. """
  5. import numpy as np
  6. import scipy.signal as ss
  7. import h5py
  8. import matplotlib.pyplot as plt
  9. class Phantom:
  10. """Generic numerical phantom for MRI simulations
  11. The phantom is mainly defined by three matrices of T1, T2, and PD values, respectively.
  12. At the moment, each index in the matrix corresponds to a single spin group.
  13. The overall physical size is determined by vsize; phantom voxels must be isotropic.
  14. Parameters
  15. ----------
  16. T1map : numpy.ndarray
  17. Matrix of T1 values in seconds
  18. T2map : numpy.ndarray
  19. Matrix of T2 values in seconds
  20. PDmap : numpy.ndarray
  21. Matrix PD values between 0 and 1
  22. vsize : float
  23. Voxel size in meters (isotropic)
  24. dBmap : numpy.ndarray, optional
  25. Matrix of B0 magnetic field variation across phantom
  26. The default is 0 and means no variation
  27. loc : tuple, optional
  28. Overall location of phantom in meters; default is (0,0,0)
  29. Attributes
  30. ----------
  31. fov : numpy.ndarray
  32. [fov_x, fov_y, fov_z]
  33. 1 x 3 array of fields-of-view in x, y, and z directions
  34. Xs : numpy.ndarray
  35. 1D array of all x locations in phantom
  36. Ys : numpy.ndarray
  37. 1D array of all y locations in phantom
  38. Zs : numpy.ndarray
  39. 1D array of all z locations in phantom
  40. """
  41. def __init__(self,T1map,T2map,PDmap,vsize,dBmap=0,loc=(0,0,0)):
  42. self.vsize = vsize
  43. self.T1map = T1map
  44. self.T2map = T2map
  45. self.PDmap = PDmap
  46. self.vsize = vsize
  47. self.dBmap = dBmap
  48. self.loc = loc
  49. # Find field-of-view
  50. self.fov = vsize*np.array(np.shape(T1map))
  51. # Make location vectors
  52. ph_shape = np.shape(self.PDmap)
  53. # Define coordinates
  54. self.Xs = self.loc[0]+np.arange(-self.fov[0] / 2 + vsize / 2, self.fov[0] / 2, vsize)
  55. self.Ys = self.loc[1]+np.arange(-self.fov[1] / 2 + vsize / 2, self.fov[1] / 2, vsize)
  56. self.Zs = self.loc[2]+np.arange(-self.fov[2] / 2 + vsize / 2, self.fov[2] / 2, vsize)
  57. def get_location(self,indx):
  58. """Returns (x,y,z) physical location in meters at given indices
  59. Parameters
  60. ----------
  61. indx : tuple or array_like
  62. (ind1, ind2, ind3)
  63. Index for querying
  64. Returns
  65. -------
  66. x, y, z : float
  67. physical location corresponding to index
  68. """
  69. return self.Xs[indx[0]], self.Ys[indx[1]], self.Zs[indx[2]]
  70. def get_shape(self):
  71. """Returns the phantom's matrix size
  72. Returns
  73. -------
  74. shape : tuple
  75. The matrix size in three dimensions
  76. """
  77. return np.shape(self.PDmap)
  78. def get_params(self,indx):
  79. """Returns PD, T1, and T2 at given indices
  80. Parameters
  81. ----------
  82. indx : tuple
  83. Index for querying
  84. Returns
  85. -------
  86. PD, T1, T2 : float
  87. Tissue parameters corresponding to the queried index
  88. """
  89. return self.PDmap[indx],self.T1map[indx],self.T2map[indx]
  90. def get_list_locs(self):
  91. """Returns a flattened 1D array of all location vectors [(x1,y1,z1),...,(xk,yk,zk)]
  92. Returns
  93. -------
  94. list_locs : list
  95. """
  96. list_locs = []
  97. for x in self.Xs:
  98. for y in self.Ys:
  99. for z in self.Zs:
  100. list_locs.append((x, y, z))
  101. return list_locs
  102. def get_list_inds(self):
  103. """Returns a flattened 1D array of all indices in phantom [(u1,v1,w1),...,(uk,vk,wk)]
  104. Returns
  105. -------
  106. list_inds : list
  107. """
  108. list_inds = []
  109. sh = self.get_shape()
  110. for u in range(sh[0]):
  111. for v in range(sh[1]):
  112. for w in range(sh[2]):
  113. list_inds.append((u,v,w))
  114. return list_inds
  115. def output_h5(self, output_folder, name='phantom'):
  116. """
  117. Inputs
  118. ------
  119. output_folder : str
  120. Folder in which to output the h5 file
  121. """
  122. GAMMA = 2 * 42.58e6 * np.pi
  123. pht_shape = list(np.flip(self.get_shape()))
  124. dim = len(pht_shape)
  125. pht_shape.append(5)
  126. PDmap_au = np.swapaxes(self.PDmap,0,-1)
  127. T1map_ms = np.swapaxes(self.T1map * 1e3, 0,-1)
  128. T2map_ms = np.swapaxes(self.T2map * 1e3,0,-1)
  129. T1map_ms_inv = np.where(T1map_ms > 0, 1/T1map_ms, 0)
  130. T2map_ms_inv = np.where(T2map_ms > 0, 1/T2map_ms, 0)
  131. if np.shape(self.dBmap) == tuple(pht_shape):
  132. dBmap_rad_per_ms = np.swapaxes(self.dBmap * GAMMA * 1e-3, 0, -1)
  133. else:
  134. dBmap_rad_per_ms = self.dBmap * GAMMA * 1e-3
  135. if len(output_folder) > 0:
  136. output_folder += '/'
  137. pht_file = h5py.File(output_folder + name + '.h5', 'a')
  138. if "sample" in pht_file.keys():
  139. del pht_file["sample"]
  140. sample = pht_file.create_group('sample')
  141. data = sample.create_dataset('data', tuple(pht_shape),
  142. dtype='f') # M0, 1/T1 [1/ms], 1/T2 [1/ms], 1/T2* [1/ms], chemical shift [rad/ms]
  143. offset = sample.create_dataset('offset', (3, 1), dtype='f')
  144. resolution = sample.create_dataset('resolution', (3, 1), dtype='f')
  145. if dim == 1:
  146. data[:, 0] = PDmap_au
  147. #data[:, 1] = 1 / T1map_ms
  148. data[:, 1] = T1map_ms_inv
  149. #data[:, 2] = 1 / T2map_ms
  150. data[:, 2] = T2map_ms_inv
  151. #data[:, 3] = 1 / T2map_ms # T2 assigned as T2* for now
  152. data[:, 3] = T2map_ms_inv
  153. data[:, 4] = dBmap_rad_per_ms
  154. elif dim == 2:
  155. data[:, :, 0] = PDmap_au
  156. #data[:, :, 1] = 1 / T1map_ms
  157. #data[:, :, 2] = 1 / T2map_ms
  158. #data[:, :, 3] = 1 / T2map_ms # T2 assigned as T2* for now
  159. data[:, :, 1] = T1map_ms_inv
  160. data[:, :, 2] = T2map_ms_inv
  161. data[:, :, 3] = T2map_ms_inv
  162. data[:, :, 4] = dBmap_rad_per_ms
  163. elif dim == 3:
  164. data[:, :, :, 0] = PDmap_au
  165. #data[:, :, :, 1] = 1 / T1map_ms
  166. #data[:, :, :, 2] = 1 / T2map_ms
  167. #data[:, :, :, 3] = 1 / T2map_ms # T2 assigned as T2* for now
  168. data[:, :, :, 1] = T1map_ms_inv
  169. data[:, :, :, 2] = T2map_ms_inv
  170. data[:, :, :, 3] = T2map_ms_inv
  171. data[:, :, :, 4] = dBmap_rad_per_ms
  172. offset[:,0] = np.array(self.loc)*1000 # meters to mm conversion
  173. resolution[:,0] = [self.vsize*1000]*3 # isotropic
  174. pht_file.close()
  175. return
  176. class DTTPhantom(Phantom):
  177. """Discrete tissue type phantom
  178. Phantom constructed from a finite set of tissue types and their parameters
  179. Parameters
  180. ----------
  181. type_map : numpy.ndarray
  182. Matrix of integers that map to tissue types
  183. type_params : dict
  184. Dictionary that maps tissue type number to tissue type parameters (PD,T1,T2)
  185. vsize : float
  186. Voxel size in meters (isotropic)
  187. dBmap : numpy.ndarray, optional
  188. Matrix of B0 magnetic field variation across phantom
  189. The default is 0 and means no variation
  190. loc : tuple, optional
  191. Overall location of phantom; default is (0,0,0)
  192. """
  193. def __init__(self,type_map,type_params,vsize,dBmap=0,loc=(0,0,0)):
  194. print(type(type_map))
  195. self.type_map = type_map
  196. self.type_params = type_params
  197. T1map = np.ones(np.shape(type_map))
  198. T2map = np.ones(np.shape(type_map))
  199. PDmap = np.zeros(np.shape(type_map))
  200. for x in range(np.shape(type_map)[0]):
  201. for y in range(np.shape(type_map)[1]):
  202. for z in range(np.shape(type_map)[2]):
  203. PDmap[x,y,z] = type_params[type_map[x,y,z]][0]
  204. T1map[x,y,z] = type_params[type_map[x,y,z]][1]
  205. T2map[x,y,z] = type_params[type_map[x,y,z]][2]
  206. super().__init__(T1map,T2map,PDmap,vsize,dBmap,loc)
  207. class BrainwebPhantom(Phantom):
  208. """This phantom is in development.
  209. """
  210. def __init__(self, filename,dsf=1,make2d=False,loc=0,dir='z',dBmap=0):
  211. dsf = int(np.absolute(dsf))
  212. bw_data = np.load(filename).all()
  213. params = {k: np.array([v[3],v[0],v[1]]) for k, v in bw_data['params'].items()}
  214. typemap = bw_data['typemap']
  215. dr = 1e-3 # 1mm voxel size
  216. # If we want planar phantom, then let's take the slice!
  217. if make2d:
  218. if dir in ['sagittal','x']:
  219. n = np.shape(typemap)[0]
  220. xx = dr*(n-1)
  221. loc_ind = int((n/xx)*loc + n/2)
  222. if loc_ind < 0:
  223. loc_ind = 0
  224. if loc_ind > n-1:
  225. loc_ind = n-1
  226. typemap = typemap[[loc_ind],:,:]
  227. elif dir in ['coronal','y']:
  228. n = np.shape(typemap)[1]
  229. yy = dr*(n-1)
  230. loc_ind = int((n/yy)*loc + n/2)
  231. if loc_ind < 0:
  232. loc_ind = 0
  233. if loc_ind > n - 1:
  234. loc_ind = n - 1
  235. typemap = typemap[:,[loc_ind],:]
  236. elif dir in ['axial','z']:
  237. n = np.shape(typemap)[2]
  238. zz = dr*(n-1)
  239. loc_ind = int((n/zz)*loc + n/2)
  240. if loc_ind < 0:
  241. loc_ind = 0
  242. if loc_ind > n - 1:
  243. loc_ind = n - 1
  244. typemap = typemap[:,:,[loc_ind]]
  245. # Make parm maps from typemap
  246. a,b,c = np.shape(typemap)
  247. T1map = np.ones((a,b,c))
  248. T2map = np.ones((a,b,c))
  249. PDmap = np.zeros((a,b,c))
  250. for x in range(a):
  251. for y in range(b):
  252. for z in range(c):
  253. PDmap[x,y,z] = params[typemap[x,y,z]][0]
  254. T1map[x,y,z] = params[typemap[x,y,z]][1]
  255. T2map[x,y,z] = params[typemap[x,y,z]][2]
  256. # Downsample maps
  257. a,b,c = np.shape(PDmap)
  258. if a == 1:
  259. ax = [1,2]
  260. elif b == 1:
  261. ax = [0,2]
  262. elif c == 1:
  263. ax = [0,1]
  264. else:
  265. ax = [0,1,2]
  266. for v in range(len(ax)):
  267. PDmap = ss.decimate(PDmap, dsf, axis=ax[v], ftype='fir')
  268. T1map = ss.decimate(T1map, dsf, axis=ax[v], ftype='fir')
  269. T2map = ss.decimate(T2map, dsf, axis=ax[v], ftype='fir')
  270. dr = dr*dsf
  271. PDmap = np.clip(PDmap,a_min=0,a_max=1)
  272. T1map = np.clip(T1map,a_min=0,a_max=None)
  273. T2map = np.clip(T2map,a_min=0,a_max=None)
  274. super().__init__(T1map,T2map,PDmap,dr,dBmap)
  275. class SpheresArrayPlanarPhantom(DTTPhantom):
  276. """2D phantom extracted from a cylinder containing spheres
  277. Regardless of dir, this will be an axial slice of a cylinder
  278. That is, the plane is constructed as a z-slice and then re-indexed to lie in the x or y plane
  279. The centers of spheres will correspond to locations before re-indexing
  280. Parameters
  281. ----------
  282. centers : list or array_like
  283. List of 3D physical locations of the spheres' centers
  284. radii : list or array_like
  285. List of radii for the spheres
  286. type_params : dict
  287. Dictionary that maps tissue type number to tissue type parameters (PD,T1,T2)
  288. fov : float
  289. Field of view (isotropic)
  290. n : int
  291. Matrix size
  292. dir : str, optional {'z','x','y'}
  293. Orientation of plane; default is z
  294. R : float, optional
  295. Cylinder's cross-section radius; default is half of fov
  296. loc : tuple, optional
  297. Overall location (x,y,z) of phantom from isocenter in meters
  298. Default is (0,0,0)
  299. """
  300. def __init__(self, centers, radii, type_params, fov, n, dir='z',R=0,loc=(0,0,0)):
  301. if R == 0:
  302. R = fov/2
  303. vsize = fov/n
  304. type_map = np.zeros((n,n,1))
  305. q = (n-1)/2
  306. centers_inds = [(np.array(c) / vsize + q) for c in centers]
  307. nc = len(centers)
  308. for r1 in range(n):
  309. for r2 in range(n):
  310. if vsize * np.sqrt((r1-q)**2+(r2-q)**2)<R:
  311. type_map[r1,r2,0] = nc + 1
  312. for k in range(len(centers_inds)):
  313. ci = centers_inds[k]
  314. d = vsize * np.sqrt((r1 - ci[0]) ** 2 + (r2 - ci[1])**2)
  315. if d <= radii[k]:
  316. type_map[r1,r2,0] = k + 1
  317. break
  318. if dir == 'x':
  319. type_map = np.swapaxes(type_map, 1, 2)
  320. type_map = np.swapaxes(type_map, 0, 1)
  321. elif dir == 'y':
  322. type_map = np.swapaxes(type_map, 0, 2)
  323. type_map = np.swapaxes(type_map, 0, 1)
  324. super().__init__(type_map, type_params, vsize, loc=loc)
  325. def makeSphericalPhantom(n,fov,T1s,T2s,PDs,radii,loc=(0,0,0)):
  326. """Make a spherical phantom with concentric layers
  327. Parameters
  328. ----------
  329. n : int
  330. Matrix size of phantom (isotropic)
  331. fov : float
  332. Field of view of phantom (isotropic)
  333. T1s : numpy.ndarray or list
  334. List of T1s in seconds for the layers, going outward
  335. T2s : numpy.ndarray or list
  336. List of T2s in seconds for the layers, going outward
  337. PDs : numpy.ndarray or list
  338. List of PDs between 0 and 1 for the layers, going outward
  339. radii : numpy.ndarray
  340. List of radii that define the layers
  341. Note that the radii are expected to go from smallest to largest
  342. If not, they will be sorted first without sorting the parameters
  343. loc : tuple, optional
  344. Overall (x,y,z) location of phantom; default is (0,0,0)
  345. Returns
  346. -------
  347. phantom : DTTPhantom
  348. """
  349. radii = np.sort(radii)
  350. m = np.shape(radii)[0]
  351. vsize = fov/n
  352. type_map = np.zeros((n,n,n))
  353. type_params = {}
  354. for x in range(n):
  355. for y in range(n):
  356. for z in range(n):
  357. d = vsize*np.linalg.norm(np.array([x,y,z])-(n-1)/2)
  358. for k in range(m):
  359. if d <= radii[k]:
  360. type_map[x,y,z] = k+1
  361. break
  362. type_params[0] = (0,1,1)
  363. for k in range(m):
  364. type_params[k+1] = (PDs[k],T1s[k],T2s[k])
  365. return DTTPhantom(type_map,type_params,vsize,loc)
  366. def makePlanarPhantom(n,fov,T1s,T2s,PDs,radii,dir='z',loc=(0,0,0)):
  367. """Make a circular 2D phantom with concentric layers
  368. Parameters
  369. ----------
  370. n : int
  371. Matrix size of phantom (isotropic)
  372. fov : float
  373. Field of view of phantom (isotropic)
  374. T1s : numpy.ndarray or list
  375. List of T1s in seconds for the layers, going outward
  376. T2s : numpy.ndarray or list
  377. List of T2s in seconds for the layers, going outward
  378. PDs : numpy.ndarray or list
  379. List of PDs between 0 and 1 for the layers, going outward
  380. radii : numpy.ndarray
  381. List of radii that define the layers
  382. Note that the radii are expected to go from smallest to largest
  383. If not, they will be sorted first without sorting the parameters
  384. dir : str, optional {'z','x','y'}
  385. Orientation of the plane; default is z, axial
  386. loc : tuple, optional
  387. Overall (x,y,z) location of phantom; default is (0,0,0)
  388. Returns
  389. -------
  390. phantom : DTTPhantom
  391. """
  392. radii = np.sort(radii)
  393. m = np.shape(radii)[0]
  394. vsize = fov / n
  395. type_map = np.zeros((n, n, 1))
  396. type_params = {}
  397. for x in range(n):
  398. for y in range(n):
  399. d = vsize * np.linalg.norm(np.array([x, y]) - (n - 1) / 2)
  400. for k in range(m):
  401. if d <= radii[k]:
  402. type_map[x,y,0] = k + 1
  403. break
  404. type_params[0] = (0, 1, 1)
  405. for k in range(m):
  406. type_params[k + 1] = (PDs[k], T1s[k], T2s[k])
  407. if dir == 'x':
  408. type_map = np.swapaxes(type_map,1,2)
  409. type_map = np.swapaxes(type_map,0,1)
  410. elif dir =='y':
  411. type_map = np.swapaxes(type_map,0,2)
  412. type_map = np.swapaxes(type_map,0,1)
  413. return DTTPhantom(type_map, type_params, vsize, loc)
  414. def makeCylindricalPhantom(dim=2,n=16,dir='z',loc=0,fov=0.24):
  415. """Makes a cylindrical phantom with fixed geometry and T1, T2, PD but variable resolution and overall size
  416. The cylinder's diameter is the same as its height; three layers of spheres represent T1, T2, and PD variation.
  417. Parameters
  418. ----------
  419. dim : int, optional {2,3}
  420. Dimension of phantom created
  421. n : int
  422. Number of spin groups in each dimension; default is 16
  423. dir : str, optional {'z', 'x', 'y'}
  424. Direction (norm) of plane in the case of 2D phantom
  425. loc : float, optional
  426. Location of plane relative to isocenter; default is 0
  427. fov : float, optional
  428. Physical length for both diameter and height of cylinder
  429. Returns
  430. -------
  431. phantom : DTTPhantom
  432. """
  433. R = fov/2 # m
  434. r = R/4 # m
  435. h = fov # m
  436. s2 = np.sqrt(2)
  437. s3 = np.sqrt(3)
  438. vsize = fov/n
  439. centers = [(0,R/2,0.08),(-R*s3/4,-R/4,0.08),(R*s3/4,-R/4,0.08), # PD spheres
  440. (R/(2*s2),R/(2*s2),0),(-R/(2*s2),R/(2*s2),0),(-R/(2*s2),-R/(2*s2),0),(R/(2*s2),-R/(2*s2),0), # T1 spheres
  441. (0,R/2,-0.08),(-R/2,0,-0.08),(0,-R/2,-0.08),(R/2,0,-0.08)] # T2 spheres
  442. centers_inds = [(np.array(c)/vsize + (n-1)/2) for c in centers]
  443. type_params = {0:(0,1,1), # background
  444. 1:(1,0.5,0.1),2:(0.75,0.5,0.1),3:(0.5,0.5,0.1), # PD spheres
  445. 4:(0.75,1.5,0.1),5:(0.75,0.6,0.1),6:(0.75,0.25,0.1),7:(0.75,0.1,0.1), # T1 spheres
  446. 8:(0.75,0.5,0.5),9:(0.75,0.5,0.15),10:(0.75,0.5,0.05),11:(0.75,0.5,0.01), # T2 spheres
  447. 13:(0.25,0.5,0.1)}
  448. q = (n - 1) / 2
  449. p = 'xyz'.index(dir)
  450. pht_loc = (0, 0, 0)
  451. if dim == 3:
  452. type_map = np.zeros((n, n, n))
  453. for x in range(n):
  454. for y in range(n):
  455. for z in range(n):
  456. if vsize*np.sqrt((x-q)**2 + (y-q)**2) < R:
  457. type_map[x,y,z] = 13
  458. for k in range(len(centers_inds)):
  459. ci = centers_inds[k]
  460. d = vsize*np.sqrt((x-ci[0])**2+(y-ci[1])**2+(z-ci[2])**2)
  461. if d <= r:
  462. type_map[x, y, z] = k + 1
  463. break
  464. elif dim == 2:
  465. pht_loc = np.roll((loc,0,0),p)
  466. # 2D phantom
  467. type_map = np.zeros(np.roll((1,n,n),p))
  468. for r1 in range(n):
  469. for r2 in range(n):
  470. x,y,z = np.roll([q+loc/vsize,r1,r2],p)
  471. u,v,w = np.roll((0,r1,r2),p)
  472. if vsize*np.sqrt((x-q)**2 + (y-q)**2) < R:
  473. type_map[u,v,w] = 13
  474. for k in range(len(centers_inds)):
  475. ci = centers_inds[k]
  476. d = vsize*np.sqrt((x-ci[0])**2+(y-ci[1])**2+(z-ci[2])**2)
  477. if d <= r:
  478. type_map[u,v,w] = k + 1
  479. break
  480. else:
  481. raise ValueError('#Dimensions must be 2 or 3')
  482. phantom = DTTPhantom(type_map, type_params, vsize, loc=(0,0,0))
  483. return phantom
  484. if __name__ == '__main__':
  485. pht = makeCylindricalPhantom(dim=2, n=16, dir='z', loc=-0.08, fov = 0.25)
  486. plt.imshow(pht.PDmap)
  487. plt.show()
  488. print(pht.loc)