Skip to content

Commit 1cfb984

Browse files
SM7: Read lon file (#1)
* created arrows class to store lines + vector visuals * read lon to obtain fibres data * write lon and elem files * check linear connections and arrows exist before exporting * create empty arrows class if arrows not provided * export vtx files per pacing site * Feature/SM-31: IGB data load (#2) * load igb data * added load igb to imports * fibres filled with dummy values * fibres filled with dummy values * fibres filled with dummy values * Feature/SM-45: Write to CSV (#3) * export CSV writing to file using Panda * Bugfix/SM-63 Empty signalmaps (#4) * load empty signalMaps * Feature/SM-18: Linear connections writer (#5) * openCARP reader saves linear connection regions as separate data structure * faster write function for openCARP data * Feature/SM-45: Export CSV cell and histogram regions (#6) * include histogram and cell region in csv export * Bugfix/SM-86: Free boundaries as one actor (#8) * combine free boundaries into a single actor * not add rf to export openep if ablation == None (#9)
1 parent aa53319 commit 1cfb984

File tree

7 files changed

+344
-75
lines changed

7 files changed

+344
-75
lines changed

openep/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
__all__ = ['case', 'mesh', 'draw']
2020

21-
from .io.readers import load_openep_mat, load_opencarp, load_circle_cvi, load_vtk
21+
from .io.readers import load_openep_mat, load_opencarp, load_circle_cvi, load_vtk, load_igb
2222
from .io.writers import export_openCARP, export_openep_mat, export_vtk
2323
from .converters.pyvista_converters import from_pyvista, to_pyvista
2424
from . import case, mesh, draw

openep/data_structures/arrows.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from attr import attrs
2+
import numpy as np
3+
4+
__all__ = []
5+
6+
7+
@attrs(auto_attribs=True, auto_detect=True)
8+
class Arrows:
9+
"""
10+
Class for storing information about arrows and lines on surface
11+
12+
Args:
13+
fibres (np.ndarray): array of shape N_cells x 3
14+
divergence (np.ndarray): array of shape N_cells x 3
15+
linear_connections (np.ndarray): array of shape M x 3 (represents the linear connections between endo and epi)
16+
linear_connection_regions (np.ndarray): array of shape N_cells
17+
"""
18+
19+
# TODO: move divergence arrows into Arrows class
20+
# TODO: remove longitudinal and transversal arrows from Fields class
21+
fibres: np.ndarray = None
22+
divergence: np.ndarray = None
23+
linear_connections: np.ndarray = None
24+
linear_connection_regions: np.ndarray = None
25+
26+
def __repr__(self):
27+
return f"arrows: {tuple(self.__dict__.keys())}"
28+
29+
def __getitem__(self, arrow):
30+
try:
31+
return self.__dict__[arrow]
32+
except KeyError:
33+
raise ValueError(f"There is no arrow '{arrow}'.")
34+
35+
def __setitem__(self, arrow, value):
36+
if arrow not in self.__dict__.keys():
37+
raise ValueError(f"'{arrow}' is not a valid arrow name.")
38+
self.__dict__[arrow] = value
39+
40+
def __iter__(self):
41+
return iter(self.__dict__.keys())
42+
43+
def __contains__(self, arrow):
44+
return arrow in self.__dict__.keys()
45+
46+
@property
47+
def linear_connection_regions_names(self):
48+
if self.linear_connection_regions is None:
49+
return []
50+
regions = np.unique(self.linear_connection_regions).astype(str)
51+
return regions.tolist()
52+
53+
def copy(self):
54+
"""Create a deep copy of Arrows"""
55+
56+
arrows = Arrows()
57+
for arrow in self:
58+
if self[arrow] is None:
59+
continue
60+
arrows[arrow] = np.array(self[arrow])
61+
62+
return arrows

openep/data_structures/case.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
import pyvista
8484

8585
from .surface import Fields
86+
from .arrows import Arrows
8687
from .electric import Electric, Electrogram, Annotations, ElectricSurface
8788
from .ablation import Ablation
8889
from ..analysis.analyse import Analyse
@@ -122,6 +123,7 @@ def __init__(
122123
electric: Electric,
123124
ablation: Optional[Ablation] = None,
124125
notes: Optional[List] = None,
126+
arrows: Optional[Arrows] = None,
125127
):
126128

127129
self.name = name
@@ -131,6 +133,7 @@ def __init__(
131133
self.ablation = ablation
132134
self.electric = electric
133135
self.notes = notes
136+
self.arrows = Arrows() if arrows is None else arrows
134137
self.analyse = Analyse(case=self)
135138

136139
def __repr__(self):

openep/data_structures/surface.py

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class Fields:
3636
local_activation_time (np.ndarray): array of shape N_points
3737
impedance (np.ndarray): array of shape N_points
3838
force (np.ndarray): array of shape N_points
39-
region (np.ndarray): array of shape N_cells
39+
cell_region (np.ndarray): array of shape N_cells
4040
longitudinal_fibres (np.ndarray): array of shape N_cells x 3
4141
transverse_fibres (np.ndarray): array of shape N_cells x 3
4242
pacing_site (np.ndarray): array of shape N_points
@@ -54,6 +54,7 @@ class Fields:
5454
pacing_site: np.ndarray = None
5555
conduction_velocity: np.ndarray = None
5656
cv_divergence: np.ndarray = None
57+
histogram: np.ndarray = None
5758

5859
def __repr__(self):
5960
return f"fields: {tuple(self.__dict__.keys())}"
@@ -187,18 +188,22 @@ def extract_surface_data(surface_data):
187188
if isinstance(pacing_site, np.ndarray):
188189
pacing_site = None if pacing_site.size == 0 else pacing_site.astype(int)
189190

190-
try:
191-
conduction_velocity = surface_data['signalMaps']['conduction_velocity_field'].get('value', None)
192-
if isinstance(conduction_velocity, np.ndarray):
193-
conduction_velocity = None if conduction_velocity.size == 0 else conduction_velocity.astype(float)
194-
except KeyError:
195-
conduction_velocity = None
191+
if surface_data.get('signalMaps'):
192+
try:
193+
conduction_velocity = surface_data['signalMaps']['conduction_velocity_field'].get('value', None)
194+
if isinstance(conduction_velocity, np.ndarray):
195+
conduction_velocity = None if conduction_velocity.size == 0 else conduction_velocity.astype(float)
196+
except KeyError:
197+
conduction_velocity = None
196198

197-
try:
198-
cv_divergence = surface_data['signalMaps']['divergence_field'].get('value', None)
199-
if isinstance(cv_divergence, np.ndarray):
200-
cv_divergence = None if cv_divergence.size == 0 else cv_divergence.astype(float)
201-
except KeyError:
199+
try:
200+
cv_divergence = surface_data['signalMaps']['divergence_field'].get('value', None)
201+
if isinstance(cv_divergence, np.ndarray):
202+
cv_divergence = None if cv_divergence.size == 0 else cv_divergence.astype(float)
203+
except KeyError:
204+
cv_divergence = None
205+
else:
206+
conduction_velocity = None
202207
cv_divergence = None
203208

204209
fields = Fields(

openep/draw/draw_routines.py

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,10 @@ def draw_free_boundaries(
6363
width: int = 5,
6464
plotter: pyvista.Plotter = None,
6565
names: List[str] = None,
66+
combine: bool = False
6667
):
6768
"""
68-
Draw the freeboundaries of a mesh.
69+
Draw the free boundaries of a mesh.
6970
7071
Args:
7172
free_boundaries (FreeBoundary): `FreeBoundary` object. Can be generated using
@@ -76,28 +77,47 @@ def draw_free_boundaries(
7677
If None, a new plotting object will be created.
7778
names (List(str)): List of names to associated with the actors. Default is None, in which
7879
case actors will be called 'free_boundary_n', where n is the index of the boundary.
80+
combine (bool): Combines all free boundaries into one Actor (faster load time).
7981
8082
Returns:
8183
plotter (pyvista.Plotter): Plotting object with the free boundaries added.
82-
8384
"""
84-
85+
combined_lines = pyvista.PolyData() if combine else None
8586
plotter = pyvista.Plotter() if plotter is None else plotter
8687
colours = [colour] * free_boundaries.n_boundaries if isinstance(colour, str) else colour
88+
8789
if names is None:
8890
names = [f"free_boundary_{boundary_index:d}" for boundary_index in range(free_boundaries.n_boundaries)]
8991

9092
for boundary_index, boundary in enumerate(free_boundaries.separate_boundaries()):
9193

9294
points = free_boundaries.points[boundary[:, 0]]
9395
points = np.vstack([points, points[:1]]) # we need to close the loop
94-
plotter.add_lines(
95-
points,
96-
color=colours[boundary_index],
97-
width=width,
98-
name=names[boundary_index],
99-
connected=True
100-
)
96+
97+
# store the lines to be added in later
98+
if combine:
99+
lines = pyvista.lines_from_points(points)
100+
combined_lines = combined_lines.merge(lines)
101+
102+
# add each line one-by-one
103+
else:
104+
plotter.add_lines(
105+
points,
106+
color=colours[boundary_index],
107+
width=width,
108+
name=names[boundary_index],
109+
connected=True
110+
)
111+
112+
if combine:
113+
# add the combined lines as a single Actor manually (modified code of add_lines)
114+
actor = pyvista.Actor(mapper=pyvista.DataSetMapper(combined_lines))
115+
actor.prop.line_width = width
116+
actor.prop.show_edges = True
117+
actor.prop.edge_color = colour
118+
actor.prop.color = colour
119+
actor.prop.lighting = False
120+
plotter.add_actor(actor, reset_camera=False, name=names[0], pickable=False)
101121

102122
return plotter
103123

openep/io/readers.py

Lines changed: 73 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"""
5454

5555
import os
56+
import re
5657
import scipy.io
5758

5859
import numpy as np
@@ -63,9 +64,10 @@
6364
from ..data_structures.surface import extract_surface_data, Fields
6465
from ..data_structures.electric import extract_electric_data, Electric
6566
from ..data_structures.ablation import extract_ablation_data, Ablation
67+
from ..data_structures.arrows import Arrows
6668
from ..data_structures.case import Case
6769

68-
__all__ = ["load_openep_mat", "_load_mat", "load_opencarp", "load_circle_cvi", "load_vtk"]
70+
__all__ = ["load_openep_mat", "_load_mat", "load_opencarp", "load_circle_cvi", "load_vtk", "load_igb"]
6971

7072

7173
def _check_mat_version_73(filename):
@@ -158,28 +160,43 @@ def load_opencarp(
158160

159161
points_data = np.loadtxt(points, skiprows=1)
160162
points_data *= scale_points
161-
indices_data = np.loadtxt(indices, skiprows=1, usecols=[1, 2, 3], dtype=int)
162-
cell_region = np.loadtxt(indices, skiprows=1, usecols=4, dtype=int)
163-
164-
longitudinal_fibres = None
165-
transverse_fibres = None
166-
if fibres is not None:
167-
fibres_data = np.loadtxt(fibres, skiprows=1, dtype=float)
168-
longitudinal_fibres = fibres_data[:, :3]
169-
if fibres_data.shape[1] == 6:
170-
transverse_fibres = fibres_data[:, 3:]
171-
172-
# Create empty data structures for pass to Case
163+
fibres_data = None if fibres is None else np.loadtxt(fibres)
164+
165+
indices_data, cell_region_data = [], []
166+
linear_connection_data, linear_connection_regions = [], []
167+
168+
with open(indices) as elem_file:
169+
data = elem_file.readlines()
170+
for elem in data:
171+
parts = elem.strip().split()
172+
if parts[0] == 'Tr':
173+
indices_data.append(list(map(int, parts[1:4])))
174+
cell_region_data.append(int(parts[4]))
175+
elif parts[0] == 'Ln':
176+
linear_connection_data.append(list(map(int, parts[1:3])))
177+
linear_connection_regions.append(int(parts[3]))
178+
179+
indices_data = np.array(indices_data)
180+
cell_region = np.array(cell_region_data)
181+
linear_connection_data = np.array(linear_connection_data)
182+
linear_connection_regions = np.array(linear_connection_regions)
183+
184+
arrows = Arrows(
185+
fibres=fibres_data,
186+
linear_connections=linear_connection_data,
187+
linear_connection_regions=linear_connection_regions
188+
)
189+
173190
fields = Fields(
174191
cell_region=cell_region,
175-
longitudinal_fibres=longitudinal_fibres,
176-
transverse_fibres=transverse_fibres,
192+
longitudinal_fibres=None,
193+
transverse_fibres=None,
177194
)
178195
electric = Electric()
179196
ablation = Ablation()
180197
notes = np.asarray([], dtype=object)
181198

182-
return Case(name, points_data, indices_data, fields, electric, ablation, notes)
199+
return Case(name, points_data, indices_data, fields, electric, ablation, notes, arrows)
183200

184201

185202
def load_vtk(filename, name=None):
@@ -281,3 +298,43 @@ def load_circle_cvi(filename, dicoms_directory, extract_epi=True, extract_endo=T
281298
return epi_mesh
282299
elif extract_endo:
283300
return endo_mesh
301+
302+
303+
def load_igb(igb_filepath):
304+
"""
305+
Reads an .igb file, returning the data and header information.
306+
307+
Args:
308+
igb_filepath (str): Path to the .igb file.
309+
310+
Returns:
311+
tuple:
312+
- numpy.ndarray: 2D array of the file's data.
313+
- dict: Contents of the header including 't' value (time steps) and other parameters.
314+
"""
315+
with open(igb_filepath, 'rb') as file:
316+
header = file.read(1024).decode('utf-8')
317+
header = header.replace('\r', ' ').replace('\n', ' ').replace('\0', ' ')
318+
hdr_content = {}
319+
320+
# Parse the header to dict format
321+
for part in header.split():
322+
key, value = part.split(':')
323+
if key in ['x', 'y', 'z', 't', 'bin', 'num', 'lut', 'comp']:
324+
hdr_content[key] = int(value)
325+
elif key in ['facteur','zero','epais'] or key.startswith('org_') or key.startswith('dim_') or key.startswith('inc_'):
326+
hdr_content[key] = float(value)
327+
else:
328+
hdr_content[key] = value
329+
330+
# Process file data
331+
words = header.split()
332+
word = [int(re.split(r"(\d+)", w)[1]) for w in words[:4]]
333+
nnode = word[0] * word[1] * word[2]
334+
size = os.path.getsize(igb_filepath) // 4 // nnode
335+
336+
file.seek(1024)
337+
data = np.fromfile(file, dtype=np.float32, count=size * nnode)
338+
data = data.reshape((size, nnode)).transpose()
339+
340+
return data, hdr_content

0 commit comments

Comments
 (0)