2727from deprecated import deprecated
2828import numpy as np
2929import pyvista as pv
30+ from scipy .spatial .transform import Rotation as R # noqa N817
3031
3132from ansys .health .heart import LOG as LOGGER
3233from ansys .health .heart .exceptions import D3PlotNotSupportedError
@@ -115,43 +116,45 @@ def update_transmural_by_normal(grid: pv.UnstructuredGrid, surface: pv.PolyData)
115116 return grad_trans
116117
117118
118- def orthogonalization (
119- grad_trans : np .ndarray , k : np .ndarray
120- ) -> tuple [np .ndarray , np .ndarray , np .ndarray ]:
121- """Orthogonalization.
119+ def orthogonalization (e1 : np .ndarray , e2 : np .ndarray ) -> tuple [np .ndarray , np .ndarray , np .ndarray ]:
120+ """Create a orthonormal coordinate system.
122121
123122 Parameters
124123 ----------
125- grad_trans : np.ndarray
126- Transmural vector.
127- k : np.ndarray
128- Bundle selection vector.
124+ e1 : np.ndarray
125+ First unit (N,M) vector of the coordinate system.
126+ e2 : np.ndarray
127+ Second unit (N,M) vector of the coordinate system.
128+
129+ Notes
130+ -----
131+ e3 is orthogonal to the plane spanned by e1 and e2 following the right hand rule.
132+ Project e2 onto e1, and subtract to ensure orthogonality.
129133
130134 Returns
131135 -------
132136 tuple[np.ndarray, np.ndarray, np.ndarray]
133- Local coordinate system ``e_l, e_n, e_t ``.
137+ Local orthonormal coordinate system ``e1, e2, e3 ``.
134138 """
135- norm = np .linalg .norm (grad_trans , axis = 1 )
136- bad_cells = np .argwhere (norm == 0 ).ravel ()
139+ e1_norm = np .linalg .norm (e1 , axis = 1 )
140+ bad_vectors = np .argwhere (e1_norm == 0 ).ravel ()
137141
138- LOGGER .debug (
139- f" { len ( bad_cells ) } cells have null gradient in transmural direction."
140- f" This should only be at valve regions and can be checked from the VTK file."
141- )
142+ LOGGER .debug (f" { len ( bad_vectors ) } vectors have length zero." )
143+
144+ e1_norm = np . where ( e1_norm != 0 , e1_norm , 1 )
145+ e1 = e1 / e1_norm [:, None ]
142146
143- norm = np .where (norm != 0 , norm , 1 )
144- e_t = grad_trans / norm [:, None ]
147+ # Ensure e1 and e2 are orthogonal
148+ dot_prod = np .einsum ("ij,ij->i" , e1 , e2 )
149+ e2 = e2 - dot_prod [:, None ] * e1
145150
146- k_e = np .einsum ("ij,ij->i" , k , e_t )
147- en = k - np .einsum ("i,ij->ij" , k_e , e_t )
148- norm = np .linalg .norm (en , axis = 1 )
149- norm = np .where (norm != 0 , norm , 1 )
150- e_n = en / norm [:, None ]
151+ # Normalize
152+ e2 /= np .linalg .norm (e2 , axis = 1 )[:, None ]
151153
152- e_l = np .cross (e_n , e_t )
154+ # Use right hand rule to compute e3
155+ e3 = np .cross (e1 , e2 )
153156
154- return e_l , e_n , e_t
157+ return e1 , e2 , e3
155158
156159
157160def compute_la_fiber_cs (
@@ -224,7 +227,8 @@ def bundle_selection(grid):
224227
225228 bundle_selection (grid )
226229
227- el , en , et = orthogonalization (grid ["grad_trans" ], grid ["k" ])
230+ et , en , _ = orthogonalization (grid ["grad_trans" ], grid ["k" ])
231+ el = np .cross (en , et )
228232
229233 grid .cell_data ["e_l" ] = el
230234 grid .cell_data ["e_n" ] = en
@@ -364,7 +368,8 @@ def bundle_selection(grid):
364368
365369 bundle_selection (grid )
366370
367- el , en , et = orthogonalization (grid ["grad_trans" ], grid ["k" ])
371+ et , en , _ = orthogonalization (grid ["grad_trans" ], grid ["k" ])
372+ el = np .cross (en , et )
368373
369374 grid .cell_data ["e_l" ] = el
370375 grid .cell_data ["e_n" ] = en
@@ -414,6 +419,7 @@ def _sigmoid(z):
414419 return ro_endo , ro_epi
415420
416421
422+ @deprecated (reason = "Use _compute_rotation_angles instead." )
417423def compute_rotation_angle (
418424 grid : pv .UnstructuredGrid ,
419425 w : np .ndarray ,
@@ -445,16 +451,34 @@ def compute_rotation_angle(
445451 rot_endo , rot_epi = set_rotation_bounds (w , rotation [0 ], rotation [1 ], outflow_tracts )
446452
447453 # interpolate along transmural direction
454+ # follow definition in Doste et al:
455+ # α = α_endo(w) · (1 − d) + α_epi(w) · d
456+
448457 angle = np .zeros (grid .n_cells )
449- angle = rot_epi * (np .ones (grid .n_cells ) - grid ["d" ]) + rot_endo * grid ["d" ]
458+ # angle = rot_epi * (np.ones(grid.n_cells) - grid["d"]) + rot_endo * grid["d"]
459+ angle = rot_endo * (np .ones (grid .n_cells ) - grid ["d" ]) + rot_epi * grid ["d" ]
460+ return angle
461+
462+
463+ def _compute_rotation_angle (
464+ transmural_distance : float | list | np .ndarray ,
465+ rotation_endocardium : float ,
466+ rotation_epicardium : float ,
467+ ):
468+ """Compute the rotation angle a for a given transmural depth and weight factor."""
469+ # follow definition in Doste et al:
470+ # α = α_endo(w) · (1 − d) + α_epi(w) · d
471+ angle = (
472+ rotation_endocardium * (1 - transmural_distance ) + rotation_epicardium * transmural_distance
473+ )
450474 return angle
451475
452476
453477def compute_ventricle_fiber_by_drbm (
454478 directory : str ,
455479 settings : dict = {
456- "alpha_left" : [- 60 , 60 ],
457- "alpha_right" : [- 60 , 60 ],
480+ "alpha_left" : [60 , - 60 ],
481+ "alpha_right" : [90 , - 25 ],
458482 "alpha_ot" : None ,
459483 "beta_left" : [- 65 , 25 ],
460484 "beta_right" : [- 65 , 25 ],
@@ -486,7 +510,7 @@ def compute_ventricle_fiber_by_drbm(
486510 """
487511 solutions = ["trans" , "ab_l" , "ot_l" , "w_l" ]
488512 if not left_only :
489- solutions .extend (["ab_r" , "ot_r" , "w_r" , "lr" ])
513+ solutions .extend (["ab_r" , "ot_r" , "w_r" ])
490514
491515 data = read_laplace_solution (directory , field_list = solutions , read_heatflux = True )
492516 grid = data .point_data_to_cell_data ()
@@ -495,14 +519,14 @@ def compute_ventricle_fiber_by_drbm(
495519 # label to 1 for all cells
496520 left_mask = np .ones (grid .n_cells , dtype = bool )
497521 grid .cell_data ["label" ] = np .ones (grid .n_cells , dtype = int )
522+ right_mask = np .invert (left_mask )
498523 else :
499524 # label to 1 for left ventricle, 2 for right ventricle
500- left_mask = grid ["lr" ] >= 0
501- right_mask = grid ["lr" ] < 0
502- label = np .zeros (grid .n_cells , dtype = int )
503- label [left_mask ] = 1
504- label [right_mask ] = 2
505- grid .cell_data ["label" ] = label
525+ left_mask = grid ["trans" ] <= 0
526+ right_mask = grid ["trans" ] > 0
527+ grid .cell_data ["label" ] = np .zeros (grid .n_cells , dtype = int )
528+ grid .cell_data ["label" ][left_mask ] = 1
529+ grid .cell_data ["label" ][right_mask ] = 2
506530
507531 # normal direction
508532 k = np .zeros ((grid .n_cells , 3 ))
@@ -517,70 +541,78 @@ def compute_ventricle_fiber_by_drbm(
517541
518542 grid .cell_data ["k" ] = k
519543
520- # build local coordinate system
521- if not left_only :
522- grid .cell_data ["grad_trans" ][right_mask ] *= - 1.0 # both LV & RV point to inside
544+ # Build local coordinate system:
545+ # The right ventricle transmural gradient is flipped to ensure
546+ # a consistent coordinate system:
547+ # e_t points from endocardium to epicardium
548+ # e_n points from apex to base
549+ # e_c = e_n x e_t
550+ grid .cell_data ["grad_trans" ][right_mask ] *= - 1.0 # both LV & RV point to inside
551+
552+ # Create orthonormal coordinate system
553+ en , et , ec = orthogonalization (k , grid ["grad_trans" ])
523554
524- el , en , et = orthogonalization (grid ["grad_trans" ], k )
555+ # Add (unrotated) local coordinate system
556+ grid .cell_data ["e_c" ] = ec # circumferential direction
557+ grid .cell_data ["e_n" ] = en # normal/longitudinal direction
558+ grid .cell_data ["e_t" ] = et # transmural direction
525559
526- # normalized transmural distance
527560 if left_only :
528561 grid ["d" ] = grid ["trans" ]
529562 else :
530- d_l = grid ["trans" ] / 2
531- d_r = np .absolute (grid ["trans" ])
563+ # normalize transmural distance to [0,1) in each ventricle
564+ # where 0 is endocardium, and 1 is epicardium
565+ d_l = np .absolute (grid ["trans" ][left_mask ] / 2 )
566+ d_r = np .absolute (grid ["trans" ][right_mask ])
532567 grid ["d" ] = np .zeros (grid .n_cells , dtype = float )
533- grid ["d" ][left_mask ] = d_l [left_mask ]
534- grid ["d" ][right_mask ] = d_r [right_mask ]
568+ grid ["d" ][left_mask ] = d_l
569+ grid ["d" ][right_mask ] = d_r
570+ grid ["d" ] = grid ["d" ] * - 1 + 1
535571
536- # rotation angles for each cell
572+ # rotation angles alpha and beta for each cell
537573 alpha = np .zeros (grid .n_cells )
538574 beta = np .zeros (grid .n_cells )
539575
540- alpha [left_mask ] = compute_rotation_angle (
541- grid , grid [ "w_l" ] , settings ["alpha_left" ], settings ["alpha_ot" ]
542- )[ left_mask ]
543- beta [ left_mask ] = compute_rotation_angle (
544- grid , grid [ "w_l" ] , settings ["beta_left" ] , settings ["beta_ot" ]
545- )[ left_mask ]
576+ alpha [left_mask ] = _compute_rotation_angle (
577+ grid [ "d" ][ left_mask ] , settings ["alpha_left" ][ 0 ] , settings ["alpha_left" ][ 1 ]
578+ )
579+ alpha [ right_mask ] = _compute_rotation_angle (
580+ grid [ "d" ][ right_mask ] , settings ["alpha_right" ][ 0 ] , settings ["alpha_right" ][ 1 ]
581+ )
546582
547- if not left_only :
548- alpha [right_mask ] = compute_rotation_angle (
549- grid , grid ["w_r" ], settings ["alpha_right" ], settings ["alpha_ot" ]
550- )[right_mask ]
551- beta [right_mask ] = compute_rotation_angle (
552- grid , grid ["w_r" ], settings ["beta_right" ], settings ["beta_ot" ]
553- )[right_mask ]
554-
555- # save data for inspection
583+ beta [left_mask ] = _compute_rotation_angle (
584+ grid ["d" ][left_mask ], settings ["beta_left" ][0 ], settings ["beta_left" ][1 ]
585+ )
586+ beta [right_mask ] = _compute_rotation_angle (
587+ grid ["d" ][right_mask ], settings ["beta_right" ][0 ], settings ["beta_right" ][1 ]
588+ )
589+
590+ # save rotation angles
556591 grid .cell_data ["alpha" ] = alpha
557592 grid .cell_data ["beta" ] = beta
558593
559- #
560- grid .cell_data ["fiber" ] = np .zeros ((grid .n_cells , 3 ))
561-
562- # use f,n,s in Quateroni, it's n, cross fiber
563- # use FTS in Bayer, it's S, sheet normal
564- grid .cell_data ["cross-fiber" ] = np .zeros ((grid .n_cells , 3 ))
565-
566- # use f,n,s in Quateroni, it's s, sheet
567- # use FTS in Bayer, it's T, transverse
568- grid .cell_data ["sheet" ] = np .zeros ((grid .n_cells , 3 ))
569-
570- # apply rotation
571- for i in range (grid .n_cells ):
572- q = np .array ([el [i ], en [i ], et [i ]]).T
573- # rotate alpha around e_t
574- a = alpha [i ] * np .pi / 180
575- rot1 = np .array ([[np .cos (a ), - np .sin (a ), 0 ], [np .sin (a ), np .cos (a ), 0 ], [0 , 0 , 1 ]])
576- # rotate beta around e_l
577- b = beta [i ] * np .pi / 180
578- rot2 = np .array ([[1 , 0 , 0 ], [0 , np .cos (b ), np .sin (b )], [0 , - np .sin (b ), np .cos (b )]])
579- # apply rotation
580- qq = np .matmul (np .matmul (q , rot1 ), rot2 )
581-
582- grid .cell_data ["fiber" ][i ] = qq [:, 0 ]
583- grid .cell_data ["cross-fiber" ][i ] = qq [:, 1 ]
584- grid .cell_data ["sheet" ][i ] = qq [:, 2 ]
594+ # 1) rotate vector ec counterclockwise around et by an angle alpha
595+ rot_alpha = R .from_rotvec (alpha [:, None ] * et , degrees = True )
596+
597+ fibers = rot_alpha .apply (ec )
598+ cross_fibers = rot_alpha .apply (en )
599+ sheets = et
600+
601+ # 2) rotate vector ec counterclockwise around el or fibers by an angle beta
602+ rot_beta = R .from_rotvec (beta [:, None ] * fibers , degrees = True )
603+
604+ cross_fibers = rot_beta .apply (cross_fibers )
605+ sheets = rot_beta .apply (sheets )
606+
607+ # NOTE Can add additional rotation in transverse direction, by specifying a
608+ # transverse angle gamma.
609+
610+ # {f,n,s} in Piersanti et al. cross-fiber is sheet normal n
611+ # {F,T,S} in Bayer et al. cross-fiber is sheet normal S
612+ grid .cell_data ["fiber" ] = fibers
613+ grid .cell_data ["cross-fiber" ] = cross_fibers
614+ grid .cell_data ["sheet" ] = sheets
615+
616+ grid .save ("d-rbm-fibers.vtu" )
585617
586618 return grid .copy ()
0 commit comments