cardio 2025.9.0__py3-none-any.whl → 2025.10.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cardio/utils.py CHANGED
@@ -2,9 +2,6 @@
2
2
 
3
3
  import enum
4
4
 
5
- import itk
6
- import numpy as np
7
-
8
5
 
9
6
  class InterpolatorType(enum.Enum):
10
7
  """Interpolation methods for image resampling."""
@@ -13,51 +10,11 @@ class InterpolatorType(enum.Enum):
13
10
  NEAREST = "nearest"
14
11
 
15
12
 
16
- def reset_direction(
17
- image, interpolator_type: InterpolatorType = InterpolatorType.LINEAR
18
- ):
19
- """Reset image direction to identity matrix, preserving origin.
20
-
21
- This function handles the VTK reader issue where origin is not retained
22
- by using ITK to properly transform the image coordinates.
13
+ class AngleUnit(enum.Enum):
14
+ """Units for angle measurements."""
23
15
 
24
- Args:
25
- image: ITK image object
26
- interpolator_type: InterpolatorType enum for interpolation method
27
- """
28
- origin = np.asarray(itk.origin(image))
29
- spacing = np.asarray(itk.spacing(image))
30
- size = np.asarray(itk.size(image))
31
- direction = np.asarray(image.GetDirection())
32
-
33
- direction[direction == 1] = 0
34
- origin += np.dot(size, np.dot(np.diag(spacing), direction))
35
- direction = np.identity(3)
36
-
37
- origin = itk.Point[itk.F, 3](origin)
38
- spacing = itk.spacing(image)
39
- size = itk.size(image)
40
- direction = itk.matrix_from_array(direction)
41
-
42
- # Select interpolator based on type
43
- match interpolator_type:
44
- case InterpolatorType.NEAREST:
45
- interpolator = itk.NearestNeighborInterpolateImageFunction.New(image)
46
- case InterpolatorType.LINEAR:
47
- interpolator = itk.LinearInterpolateImageFunction.New(image)
48
- case _:
49
- raise ValueError(f"Unsupported interpolator type: {interpolator_type}")
50
-
51
- output = itk.resample_image_filter(
52
- image,
53
- size=size,
54
- interpolator=interpolator,
55
- output_spacing=spacing,
56
- output_origin=origin,
57
- output_direction=direction,
58
- )
59
-
60
- return output
16
+ DEGREES = "degrees"
17
+ RADIANS = "radians"
61
18
 
62
19
 
63
20
  def calculate_combined_bounds(actors):
cardio/volume.py CHANGED
@@ -6,45 +6,21 @@ import pydantic as pc
6
6
  import vtk
7
7
 
8
8
  from .object import Object
9
- from .utils import InterpolatorType, reset_direction
9
+ from .orientation import (
10
+ EulerAxis,
11
+ axcode_transform_matrix,
12
+ create_vtk_reslice_matrix,
13
+ euler_angle_to_rotation_matrix,
14
+ reset_direction,
15
+ )
10
16
  from .volume_property_presets import load_volume_property_preset
11
17
 
12
18
 
13
- def create_rotation_matrix(axis, angle_degrees):
14
- """Create rotation matrix for given axis and angle."""
15
- angle = np.radians(angle_degrees)
16
- cos_a, sin_a = np.cos(angle), np.sin(angle)
17
- if axis == "X":
18
- return np.array([[1, 0, 0], [0, cos_a, -sin_a], [0, sin_a, cos_a]])
19
- elif axis == "Y":
20
- return np.array([[cos_a, 0, sin_a], [0, 1, 0], [-sin_a, 0, cos_a]])
21
- elif axis == "Z":
22
- return np.array([[cos_a, -sin_a, 0], [sin_a, cos_a, 0], [0, 0, 1]])
23
- return np.eye(3)
24
-
25
-
26
- def create_reslice_matrix(normal, up, origin):
27
- """Create a 4x4 reslice matrix from normal vector, up vector, and origin"""
28
- normal = normal / np.linalg.norm(normal)
29
- up = up / np.linalg.norm(up)
30
- right = np.cross(normal, up)
31
- right = right / np.linalg.norm(right)
32
- up = np.cross(right, normal)
33
- matrix = vtk.vtkMatrix4x4()
34
- for i in range(3):
35
- matrix.SetElement(i, 0, right[i])
36
- matrix.SetElement(i, 1, up[i])
37
- matrix.SetElement(i, 2, normal[i])
38
- matrix.SetElement(i, 3, origin[i])
39
- matrix.SetElement(3, 3, 1.0)
40
- return matrix
41
-
42
-
43
19
  class Volume(Object):
44
20
  """Volume object with transfer functions and clipping support."""
45
21
 
46
22
  pattern: str = pc.Field(
47
- default="${frame}.nii.gz",
23
+ default="{frame}.nii.gz",
48
24
  description="Filename pattern with $frame placeholder",
49
25
  )
50
26
  transfer_function_preset: str = pc.Field(
@@ -62,7 +38,7 @@ class Volume(Object):
62
38
  logging.info(f"{self.label}: Loading frame {frame}.")
63
39
 
64
40
  image = itk.imread(path)
65
- image = reset_direction(image, InterpolatorType.LINEAR)
41
+ image = reset_direction(image)
66
42
  image = itk.vtk_image_from_image(image)
67
43
 
68
44
  mapper = vtk.vtkGPUVolumeRayCastMapper()
@@ -113,12 +89,12 @@ class Volume(Object):
113
89
  reslice.SetInputData(image_data)
114
90
  reslice.SetOutputDimensionality(2)
115
91
  reslice.SetInterpolationModeToLinear()
116
- reslice.SetBackgroundLevel(-1000.0) # Set background to air value
92
+ reslice.SetBackgroundLevel(-1000.0)
117
93
 
118
94
  # Create image actor
119
95
  actor = vtk.vtkImageActor()
120
96
  actor.GetMapper().SetInputConnection(reslice.GetOutputPort())
121
- actor.SetVisibility(False) # Start hidden
97
+ actor.SetVisibility(False)
122
98
 
123
99
  mpr_actors[orientation] = {"reslice": reslice, "actor": actor}
124
100
 
@@ -133,40 +109,19 @@ class Volume(Object):
133
109
  return mpr_actors
134
110
 
135
111
  def _setup_center_slices(self, image_data, frame: int):
136
- """Set up reslice matrices to show center slices using LAS coordinate system."""
112
+ """Set up reslice matrices to show center slices using axcode-based coordinate systems."""
137
113
  center = image_data.GetCenter()
138
-
139
114
  actors = self._mpr_actors[frame]
140
115
 
141
- # Base LAS vectors (Left-Anterior-Superior coordinate system)
142
- base_axial_normal = np.array([0.0, 0.0, 1.0]) # Z axis (Superior)
143
- base_axial_up = np.array([0.0, -1.0, 0.0]) # -Y axis (Anterior)
144
-
145
- base_sagittal_normal = np.array([1.0, 0.0, 0.0]) # X axis (Left)
146
- base_sagittal_up = np.array([0.0, 0.0, 1.0]) # Z axis (Superior)
147
-
148
- base_coronal_normal = np.array([0.0, 1.0, 0.0]) # Y axis (Posterior in data)
149
- base_coronal_up = np.array([0.0, 0.0, 1.0]) # Z axis (Superior)
116
+ # Get coordinate system transformations for each MPR view
117
+ transforms = self._get_mpr_coordinate_systems()
150
118
 
151
- # Create reslice matrices with proper LAS vectors
152
- axial_origin = [center[0], center[1], center[2]]
153
- axial_matrix = create_reslice_matrix(
154
- base_axial_normal, base_axial_up, axial_origin
155
- )
156
- actors["axial"]["reslice"].SetResliceAxes(axial_matrix)
157
-
158
- sagittal_origin = [center[0], center[1], center[2]]
159
- sagittal_matrix = create_reslice_matrix(
160
- base_sagittal_normal, base_sagittal_up, sagittal_origin
161
- )
162
- actors["sagittal"]["reslice"].SetResliceAxes(sagittal_matrix)
119
+ # Create reslice matrices directly from transforms
120
+ origin = [center[0], center[1], center[2]]
163
121
 
164
- # Coronal view: LPS->LAS Y coordinate conversion
165
- coronal_origin = [center[0], center[1], center[2]]
166
- coronal_matrix = create_reslice_matrix(
167
- base_coronal_normal, base_coronal_up, coronal_origin
168
- )
169
- actors["coronal"]["reslice"].SetResliceAxes(coronal_matrix)
122
+ for orientation in ["axial", "sagittal", "coronal"]:
123
+ mat = create_vtk_reslice_matrix(transforms[orientation], origin)
124
+ actors[orientation]["reslice"].SetResliceAxes(mat)
170
125
 
171
126
  @property
172
127
  def mpr_actors(self) -> dict[str, list[vtk.vtkImageActor]]:
@@ -179,16 +134,86 @@ class Volume(Object):
179
134
  return self.create_mpr_actors(frame)
180
135
  return self._mpr_actors[frame]
181
136
 
137
+ def _get_mpr_coordinate_systems(self):
138
+ """Get coordinate system transformation matrices for MPR views."""
139
+ view_axcodes = {
140
+ "axial": "LAS", # Left-Anterior-Superior
141
+ "sagittal": "ASL", # Anterior-Superior-Left
142
+ "coronal": "LSA", # Left-Superior-Anterior
143
+ }
144
+
145
+ transforms = {}
146
+ for view, target_axcode in view_axcodes.items():
147
+ transforms[view] = axcode_transform_matrix("LPS", target_axcode)
148
+
149
+ return transforms
150
+
151
+ def get_physical_bounds(
152
+ self, frame: int = 0
153
+ ) -> tuple[float, float, float, float, float, float]:
154
+ """Get physical coordinate bounds for the volume.
155
+
156
+ Returns:
157
+ (x_min, x_max, y_min, y_max, z_min, z_max) in LAS coordinate system
158
+ """
159
+ if not self._actors:
160
+ raise RuntimeError(f"No actors configured for volume '{self.label}'")
161
+ if frame >= len(self._actors):
162
+ raise IndexError(
163
+ f"Frame {frame} out of range for volume '{self.label}' (max: {len(self._actors) - 1})"
164
+ )
165
+
166
+ volume_actor = self._actors[frame]
167
+ image_data = volume_actor.GetMapper().GetInput()
168
+
169
+ # Get VTK image metadata
170
+ origin = np.array(image_data.GetOrigin())
171
+ spacing = np.array(image_data.GetSpacing())
172
+ dimensions = np.array(image_data.GetDimensions())
173
+ direction_matrix = np.array(
174
+ [
175
+ [image_data.GetDirectionMatrix().GetElement(i, j) for j in range(3)]
176
+ for i in range(3)
177
+ ]
178
+ )
179
+
180
+ # Calculate antiorigin using direction matrix
181
+ antiorigin = origin + direction_matrix @ (spacing * (dimensions - 1))
182
+
183
+ # Transform both corners from LPS to LAS
184
+ transform = axcode_transform_matrix("LPS", "LAS")
185
+ origin_las = origin @ transform.T
186
+ antiorigin_las = antiorigin @ transform.T
187
+
188
+ # Interleave coordinates directly without min/max
189
+ bounds = (
190
+ origin_las[0],
191
+ antiorigin_las[0], # x bounds
192
+ origin_las[1],
193
+ antiorigin_las[1], # y bounds
194
+ origin_las[2],
195
+ antiorigin_las[2], # z bounds
196
+ )
197
+
198
+ return bounds
199
+
182
200
  def update_slice_positions(
183
201
  self,
184
202
  frame: int,
185
- axial_frac: float,
186
- sagittal_frac: float,
187
- coronal_frac: float,
203
+ axial_pos: float,
204
+ sagittal_pos: float,
205
+ coronal_pos: float,
188
206
  rotation_sequence: list = None,
189
207
  rotation_angles: dict = None,
190
208
  ):
191
- """Update slice positions for MPR views with optional rotation."""
209
+ """Update slice positions for MPR views with optional rotation.
210
+
211
+ Args:
212
+ frame: Frame index
213
+ axial_pos: Physical position along Z axis (LAS Superior)
214
+ sagittal_pos: Physical position along X axis (LAS Left)
215
+ coronal_pos: Physical position along Y axis (LAS Anterior)
216
+ """
192
217
  if frame not in self._mpr_actors:
193
218
  return
194
219
 
@@ -198,65 +223,49 @@ class Volume(Object):
198
223
 
199
224
  actors = self._mpr_actors[frame]
200
225
 
201
- # Calculate slice positions from fractions
202
- axial_pos = bounds[4] + axial_frac * (bounds[5] - bounds[4]) # Z bounds
203
- sagittal_pos = bounds[0] + sagittal_frac * (bounds[1] - bounds[0]) # X bounds
204
- # Coronal: LPS->LAS Y coordinate conversion (flip direction)
205
- coronal_pos = bounds[3] - coronal_frac * (
206
- bounds[3] - bounds[2]
207
- ) # Flipped Y bounds
208
-
209
- # Base LAS vectors (Left-Anterior-Superior coordinate system)
210
- base_axial_normal = np.array([0.0, 0.0, 1.0]) # Z axis (Superior)
211
- base_axial_up = np.array([0.0, -1.0, 0.0]) # -Y axis (Anterior)
212
- base_sagittal_normal = np.array([1.0, 0.0, 0.0]) # X axis (Left)
213
- base_sagittal_up = np.array([0.0, 0.0, 1.0]) # Z axis (Superior)
214
- base_coronal_normal = np.array([0.0, 1.0, 0.0]) # Y axis (Posterior in data)
215
- base_coronal_up = np.array([0.0, 0.0, 1.0]) # Z axis (Superior)
216
-
217
- # Apply cumulative rotation if provided
226
+ # Clamp positions to volume bounds
227
+ axial_pos = max(bounds[4], min(bounds[5], axial_pos)) # Z bounds
228
+ sagittal_pos = max(bounds[0], min(bounds[1], sagittal_pos)) # X bounds
229
+ coronal_pos = max(bounds[2], min(bounds[3], coronal_pos)) # Y bounds
230
+
231
+ # Get coordinate system transformations for each MPR view
232
+ transforms = self._get_mpr_coordinate_systems()
233
+
234
+ center = image_data.GetCenter()
235
+
236
+ # Step 1: Apply translation to determine slice origins in physical space
237
+ axial_origin = [center[0], center[1], axial_pos]
238
+ sagittal_origin = [sagittal_pos, center[1], center[2]]
239
+ coronal_origin = [center[0], coronal_pos, center[2]]
240
+
241
+ # Step 2: Apply cumulative rotation around the translated origins
218
242
  if rotation_sequence and rotation_angles:
219
243
  cumulative_rotation = np.eye(3)
220
244
  for i, rotation in enumerate(rotation_sequence):
221
245
  angle = rotation_angles.get(i, 0)
222
- rotation_matrix = create_rotation_matrix(rotation["axis"], angle)
246
+ rotation_matrix = euler_angle_to_rotation_matrix(
247
+ EulerAxis(rotation["axis"]), angle
248
+ )
223
249
  cumulative_rotation = cumulative_rotation @ rotation_matrix
224
250
 
225
- # Apply rotation to base view vectors
226
- axial_normal = cumulative_rotation @ base_axial_normal
227
- axial_up = cumulative_rotation @ base_axial_up
228
- sagittal_normal = cumulative_rotation @ base_sagittal_normal
229
- sagittal_up = cumulative_rotation @ base_sagittal_up
230
- coronal_normal = cumulative_rotation @ base_coronal_normal
231
- coronal_up = cumulative_rotation @ base_coronal_up
251
+ # Apply rotation to base transforms
252
+ axial_transform = cumulative_rotation @ transforms["axial"]
253
+ sagittal_transform = cumulative_rotation @ transforms["sagittal"]
254
+ coronal_transform = cumulative_rotation @ transforms["coronal"]
232
255
  else:
233
- # Use base vectors without rotation
234
- axial_normal = base_axial_normal
235
- axial_up = base_axial_up
236
- sagittal_normal = base_sagittal_normal
237
- sagittal_up = base_sagittal_up
238
- coronal_normal = base_coronal_normal
239
- coronal_up = base_coronal_up
256
+ # Use base transforms without rotation
257
+ axial_transform = transforms["axial"]
258
+ sagittal_transform = transforms["sagittal"]
259
+ coronal_transform = transforms["coronal"]
240
260
 
241
- center = image_data.GetCenter()
242
-
243
- # Update axial slice
244
- axial_origin = [center[0], center[1], axial_pos]
245
- axial_matrix = create_reslice_matrix(axial_normal, axial_up, axial_origin)
261
+ # Update slices with translated origins and rotated transforms
262
+ axial_matrix = create_vtk_reslice_matrix(axial_transform, axial_origin)
246
263
  actors["axial"]["reslice"].SetResliceAxes(axial_matrix)
247
264
 
248
- # Update sagittal slice
249
- sagittal_origin = [sagittal_pos, center[1], center[2]]
250
- sagittal_matrix = create_reslice_matrix(
251
- sagittal_normal, sagittal_up, sagittal_origin
252
- )
265
+ sagittal_matrix = create_vtk_reslice_matrix(sagittal_transform, sagittal_origin)
253
266
  actors["sagittal"]["reslice"].SetResliceAxes(sagittal_matrix)
254
267
 
255
- # Update coronal slice
256
- coronal_origin = [center[0], coronal_pos, center[2]]
257
- coronal_matrix = create_reslice_matrix(
258
- coronal_normal, coronal_up, coronal_origin
259
- )
268
+ coronal_matrix = create_vtk_reslice_matrix(coronal_transform, coronal_origin)
260
269
  actors["coronal"]["reslice"].SetResliceAxes(coronal_matrix)
261
270
 
262
271
  def update_mpr_window_level(self, frame: int, window: float, level: float):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: cardio
3
- Version: 2025.9.0
3
+ Version: 2025.10.1
4
4
  Summary: A simple web-based viewer for 3D and 4D ('cine') medical imaging data.
5
5
  Keywords: Medical,Imaging,3D,4D,Visualization
6
6
  Author: Davis Marc Vigneault
@@ -76,7 +76,7 @@ $ uv init
76
76
  $ uv add cardio
77
77
  $ . ./.venv/bin/activate
78
78
  (project) cardio --version
79
- cardio 2025.9.0
79
+ cardio 2025.10.1
80
80
  ```
81
81
 
82
82
  ### Developing
@@ -1,28 +1,29 @@
1
- cardio/__init__.py,sha256=tEZTwdKZnx4WPaNTsZZSwBqLfCQgB0e3Rzov5-ci1GI,581
2
- cardio/app.py,sha256=jcT0AUccoB6CPAKQ_gAJE3Q-jsrc9OT4XlK1XskdrI4,1791
1
+ cardio/__init__.py,sha256=cVW5yofdKIbjqc0sf_VVnIMah9BvAgELy7IzZR50IZQ,602
2
+ cardio/app.py,sha256=TEzgA03EAgI7HSCHhYwYb8tsnAptsrpcysV5CytfAq4,1379
3
3
  cardio/assets/bone.toml,sha256=vv8uVYSHIoKuHkNCoBOkGe2_qoEbXMvQO6ypm3mMOtA,675
4
4
  cardio/assets/vascular_closed.toml,sha256=XtaZS_Zd6NSAtY3ZlUfiog3T86u9Ii0oSutU2wBQy78,1267
5
5
  cardio/assets/vascular_open.toml,sha256=1M3sV1IGt3zh_3vviysKEk9quKfjF9xUBcIq3kxVHFM,879
6
6
  cardio/assets/xray.toml,sha256=siPem0OZ2OkWH0e5pizftpItJKGJgxKJ_S2K0316ubQ,693
7
7
  cardio/blend_transfer_functions.py,sha256=s5U4hO810oE434wIkPmAP2mrAfqFb4xxxi3hHf_k8og,2982
8
8
  cardio/color_transfer_function.py,sha256=KV4j11AXYeaYGeJWBc9I-WZf7Shrm5xjQVq-0bq9Qc8,817
9
- cardio/logic.py,sha256=usIAZkF68op3apQJXR8m2y8ozuulVHlGSgX7qqyctV4,31462
10
- cardio/mesh.py,sha256=Q-5MgEoX3nInwUZe4o2MU4nKy9oR34Qj41JwevxW8bc,9410
11
- cardio/object.py,sha256=zly-2bGnB7K45gRLnMgxE-q7cUtdlOLLr4z4QoVkZJ0,6172
9
+ cardio/logic.py,sha256=a73JJ2b8t-TYggYMGI1IGpn0Q6AFD8I1JNz9fn6QeDk,37557
10
+ cardio/mesh.py,sha256=XIJX2Ldv1Bw82yykE0N4IlQ0xE2r6xitjxsbEvEKrcI,9390
11
+ cardio/object.py,sha256=FvMzhO9gyHhXgME8B6fcnWH74HmsYNoUjpVKUWEpWhM,6191
12
+ cardio/orientation.py,sha256=J3bqZbv8vfl4loGl7ksmuyqWb3zFAz-TVSIahKcg0pc,6145
12
13
  cardio/piecewise_function.py,sha256=bwtwgrAMGkgu1krnvsOF9gRMaZb6smsS9jLrgBecSbo,789
13
14
  cardio/property_config.py,sha256=XJYcKeRcq8s9W9jqxzVer75r5jBLuvebv780FYdPV8U,1723
14
- cardio/scene.py,sha256=2GAbSvFMPQAHoNz2EO4ukXkIXmfy3-Km3ik5OHdDzJE,13015
15
+ cardio/scene.py,sha256=9jskdEARyJjk7QBIcMt5X2HsnikTAJdoRoLSI0LxcJE,14301
15
16
  cardio/screenshot.py,sha256=l8bLgxnU5O0FlnmsyVAzKwM9Y0401IzcdnDP0WqFSTY,640
16
- cardio/segmentation.py,sha256=enj1V5Rw42dxjqSRUiylEQcdgvj8A-Do9Z638tf7nc8,6706
17
+ cardio/segmentation.py,sha256=SK9f834lld08qYH9l2Ligml212u7MzfcOEgTz8wnpzs,6676
17
18
  cardio/transfer_function_pair.py,sha256=90PQXByCL6mMaODo7Yfd-lmdFtCKhcBZbNaiH-PTds0,805
18
19
  cardio/types.py,sha256=DYDgA5QmYdU3QQrEgZMouEbMEIf40DJCeXo4V7cDXtg,356
19
- cardio/ui.py,sha256=ON8SUuxyo1W3m1IX18K-8wxd1dCFlNcRufxH88iXEZk,33738
20
- cardio/utils.py,sha256=zgyJ2PWTAWIaU8SVA9KF_XFB4251QxYAT6w3cqaIerA,3051
21
- cardio/volume.py,sha256=Wt1EoOJ_U2RZLjK67_iIYMjFiXIKV2DNLkXd-8qFslU,10613
20
+ cardio/ui.py,sha256=wsRujwiNtMeq65R4bx-myxSPDhJT0RskwvBWlGenmHk,42901
21
+ cardio/utils.py,sha256=tFUQ4FxfidTH6GjEIKQwguqhO9T_wJ2Vk0IhbEfxRGA,1616
22
+ cardio/volume.py,sha256=k7NYZCwsmoAbs8mNWDC51gS6nlzPqD9JhDr1iyUQ0cs,10377
22
23
  cardio/volume_property.py,sha256=6T2r67SSIDl8F6ZlQvgMCZESLxuXVVAUjOC50lgQEmk,1644
23
24
  cardio/volume_property_presets.py,sha256=U2a2MnyCjryzOLEADs3OLSMMmAUnXq82mYK7OVXbQV0,1659
24
25
  cardio/window_level.py,sha256=gjk39Iv6jMJ52y6jydOjxBZBsI1nZQMs2CdWWTshQoE,807
25
- cardio-2025.9.0.dist-info/WHEEL,sha256=4n27za1eEkOnA7dNjN6C5-O2rUiw6iapszm14Uj-Qmk,79
26
- cardio-2025.9.0.dist-info/entry_points.txt,sha256=xRd1otqKtW9xmidJ4CFRX1V9KWmsBbtxrgMtDColq3w,40
27
- cardio-2025.9.0.dist-info/METADATA,sha256=5Sf5ChcVgdhHQNZM45oqsIVPCKGkhPqqCkunHvpR0vQ,3538
28
- cardio-2025.9.0.dist-info/RECORD,,
26
+ cardio-2025.10.1.dist-info/WHEEL,sha256=4n27za1eEkOnA7dNjN6C5-O2rUiw6iapszm14Uj-Qmk,79
27
+ cardio-2025.10.1.dist-info/entry_points.txt,sha256=xRd1otqKtW9xmidJ4CFRX1V9KWmsBbtxrgMtDColq3w,40
28
+ cardio-2025.10.1.dist-info/METADATA,sha256=0GkZB7yixZKd4nFaAHgWtgCA6VxlV_pbUYdHokFROD4,3540
29
+ cardio-2025.10.1.dist-info/RECORD,,