ribline 0.3.0__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.
- ribline/__init__.py +57 -0
- ribline/configs/__init__.py +0 -0
- ribline/configs/config_schema.json +48 -0
- ribline/configs/rib_atlas.joblib +0 -0
- ribline/data/__init__.py +0 -0
- ribline/data/centerline_parser.py +39 -0
- ribline/data/dataset.py +243 -0
- ribline/data/image_reader.py +140 -0
- ribline/model/__init__.py +0 -0
- ribline/model/anatomy.py +701 -0
- ribline/model/features.py +273 -0
- ribline/model/forest.py +48 -0
- ribline/model/metrics.py +420 -0
- ribline/model/reconstruction.py +422 -0
- ribline/model/refinement.py +452 -0
- ribline/model/shape_prior.py +5 -0
- ribline/model/topology.py +570 -0
- ribline/model/unet/__init__.py +0 -0
- ribline/model/unet/dataset.py +151 -0
- ribline/model/unet/loss.py +36 -0
- ribline/model/unet/model.py +93 -0
- ribline/pipeline/__init__.py +0 -0
- ribline/pipeline/evaluate.py +196 -0
- ribline/pipeline/evaluate_single.py +62 -0
- ribline/pipeline/inference.py +225 -0
- ribline/pipeline/inference_pass2.py +202 -0
- ribline/pipeline/inference_unet.py +171 -0
- ribline/pipeline/pipeline_sota.py +216 -0
- ribline/pipeline/refine_centerlines.py +84 -0
- ribline/pipeline/train.py +126 -0
- ribline/pipeline/train_pass2.py +152 -0
- ribline/pipeline/train_unet.py +180 -0
- ribline/scripts/analyze_rib_lengths.py +84 -0
- ribline/scripts/benchmark_eval.py +290 -0
- ribline/scripts/build_atlas.py +63 -0
- ribline/scripts/correct_gt.py +176 -0
- ribline/scripts/fix_gt_annotations.py +116 -0
- ribline/scripts/polar_angle_stats.py +135 -0
- ribline/scripts/run_ablation.py +92 -0
- ribline/tests/__init__.py +0 -0
- ribline/tests/test_anatomy_and_topology.py +229 -0
- ribline/tests/test_data_loading.py +161 -0
- ribline/tests/test_metrics.py +123 -0
- ribline/tests/test_train_flow.py +86 -0
- ribline/utils/__init__.py +0 -0
- ribline-0.3.0.dist-info/METADATA +342 -0
- ribline-0.3.0.dist-info/RECORD +51 -0
- ribline-0.3.0.dist-info/WHEEL +5 -0
- ribline-0.3.0.dist-info/entry_points.txt +3 -0
- ribline-0.3.0.dist-info/licenses/LICENSE +7 -0
- ribline-0.3.0.dist-info/top_level.txt +1 -0
ribline/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""RibLine: 3D Rib Centerline Tracing and Anatomical Labeling in Medical Images."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.3.0"
|
|
4
|
+
|
|
5
|
+
from ribline.data.centerline_parser import parse_rib_xls
|
|
6
|
+
from ribline.data.dataset import RibDataset
|
|
7
|
+
from ribline.data.image_reader import load_image, read_analyze75_header, read_analyze75_numpy
|
|
8
|
+
from ribline.model.anatomy import (
|
|
9
|
+
RibCageStatisticalAtlas,
|
|
10
|
+
extract_thoracic_frame,
|
|
11
|
+
get_anatomical_rib_mask,
|
|
12
|
+
get_polar_angle,
|
|
13
|
+
standardize_hu,
|
|
14
|
+
)
|
|
15
|
+
from ribline.model.features import FeatureExtractor
|
|
16
|
+
from ribline.model.forest import RibForest
|
|
17
|
+
from ribline.model.metrics import (
|
|
18
|
+
aggregate_subject_metrics,
|
|
19
|
+
centerline_dice,
|
|
20
|
+
chamfer_distance,
|
|
21
|
+
dilated_dice,
|
|
22
|
+
evaluate_subject_ribs,
|
|
23
|
+
hausdorff_distance_95,
|
|
24
|
+
)
|
|
25
|
+
from ribline.model.reconstruction import CenterlineExtractor
|
|
26
|
+
from ribline.model.refinement import DeformableRibRefiner, RibSnake
|
|
27
|
+
from ribline.model.topology import RibTopologyMatcher
|
|
28
|
+
from ribline.model.unet.model import RibUNet, RibUNet3D
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"__version__",
|
|
32
|
+
"parse_rib_xls",
|
|
33
|
+
"RibDataset",
|
|
34
|
+
"load_image",
|
|
35
|
+
"read_analyze75_header",
|
|
36
|
+
"read_analyze75_numpy",
|
|
37
|
+
"RibCageStatisticalAtlas",
|
|
38
|
+
"extract_thoracic_frame",
|
|
39
|
+
"get_anatomical_rib_mask",
|
|
40
|
+
"get_polar_angle",
|
|
41
|
+
"standardize_hu",
|
|
42
|
+
"FeatureExtractor",
|
|
43
|
+
"RibForest",
|
|
44
|
+
"RibUNet",
|
|
45
|
+
"RibUNet3D",
|
|
46
|
+
"chamfer_distance",
|
|
47
|
+
"hausdorff_distance_95",
|
|
48
|
+
"centerline_dice",
|
|
49
|
+
"dilated_dice",
|
|
50
|
+
"evaluate_subject_ribs",
|
|
51
|
+
"aggregate_subject_metrics",
|
|
52
|
+
"CenterlineExtractor",
|
|
53
|
+
"RibTopologyMatcher",
|
|
54
|
+
"RibSnake",
|
|
55
|
+
"DeformableRibRefiner",
|
|
56
|
+
]
|
|
57
|
+
|
|
File without changes
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"title": "RibLine Configuration",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"properties": {
|
|
6
|
+
"data_config": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"properties": {
|
|
9
|
+
"image_directory": { "type": "string" },
|
|
10
|
+
"centerline_directory": { "type": "string" },
|
|
11
|
+
"split_file": { "type": "string" },
|
|
12
|
+
"select_files": {
|
|
13
|
+
"type": "array",
|
|
14
|
+
"items": { "type": "string" }
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"required": ["image_directory"]
|
|
18
|
+
},
|
|
19
|
+
"model_config": {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"properties": {
|
|
22
|
+
"n_estimators": { "type": "integer", "default": 100 },
|
|
23
|
+
"max_depth": { "type": "integer" },
|
|
24
|
+
"n_jobs": { "type": "integer", "default": -1 },
|
|
25
|
+
"dtype": { "type": "string", "enum": ["float32", "bfloat16"], "default": "float32" }
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"training_config": {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"properties": {
|
|
31
|
+
"output_directory": { "type": "string" },
|
|
32
|
+
"num_passes": { "type": "integer", "default": 1 },
|
|
33
|
+
"sampling_rate": { "type": "number", "default": 0.01 }
|
|
34
|
+
},
|
|
35
|
+
"required": ["output_directory"]
|
|
36
|
+
},
|
|
37
|
+
"eval_config": {
|
|
38
|
+
"type": "object",
|
|
39
|
+
"properties": {
|
|
40
|
+
"metrics": {
|
|
41
|
+
"type": "array",
|
|
42
|
+
"items": { "type": "string" }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"required": ["data_config", "training_config"]
|
|
48
|
+
}
|
|
Binary file
|
ribline/data/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import collections
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
def parse_rib_xls(filepath):
|
|
6
|
+
"""
|
|
7
|
+
Parses the custom tab-separated .xls ground truth files for rib centerlines.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
filepath (str): Path to the .xls file.
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
dict: Mapping from RibName (e.g., 'R01') to numpy array of coordinates (N, 3).
|
|
14
|
+
"""
|
|
15
|
+
ribs = collections.defaultdict(list)
|
|
16
|
+
try:
|
|
17
|
+
with open(filepath, 'r') as f:
|
|
18
|
+
reader = csv.reader(f, delimiter='\t')
|
|
19
|
+
for row in reader:
|
|
20
|
+
if not row: continue
|
|
21
|
+
rib_name = row[0]
|
|
22
|
+
# x, y, z are indices 1, 2, 3
|
|
23
|
+
# We assume the file format is consistent: Name, X, Y, Z, ...
|
|
24
|
+
try:
|
|
25
|
+
coords = [float(x) for x in row[1:4]]
|
|
26
|
+
ribs[rib_name].append(coords)
|
|
27
|
+
except ValueError:
|
|
28
|
+
# Skip lines that might contain headers or malformed data
|
|
29
|
+
continue
|
|
30
|
+
|
|
31
|
+
# Convert lists to numpy arrays
|
|
32
|
+
rib_dict = {}
|
|
33
|
+
for r, pts in ribs.items():
|
|
34
|
+
rib_dict[r] = np.array(pts)
|
|
35
|
+
|
|
36
|
+
return rib_dict
|
|
37
|
+
except Exception as e:
|
|
38
|
+
print(f"Error parsing {filepath}: {e}")
|
|
39
|
+
return {}
|
ribline/data/dataset.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import glob
|
|
3
|
+
import numpy as np
|
|
4
|
+
from .image_reader import load_image
|
|
5
|
+
from .centerline_parser import parse_rib_xls
|
|
6
|
+
import SimpleITK as sitk
|
|
7
|
+
|
|
8
|
+
class RibDataset:
|
|
9
|
+
def __init__(self, image_dir, centerline_dir, selected_files=None, radius=2.0):
|
|
10
|
+
"""
|
|
11
|
+
Args:
|
|
12
|
+
image_dir (str): Directory containing .img/.hdr files.
|
|
13
|
+
centerline_dir (str): Directory containing .xls centerline files.
|
|
14
|
+
selected_files (list, optional): List of filenames (without extension) to include.
|
|
15
|
+
radius (float): Radius around centerline to consider as positive class.
|
|
16
|
+
"""
|
|
17
|
+
self.image_dir = image_dir
|
|
18
|
+
self.centerline_dir = centerline_dir
|
|
19
|
+
self.radius = radius
|
|
20
|
+
self.subjects = self._discover_subjects(selected_files)
|
|
21
|
+
|
|
22
|
+
def _discover_subjects(self, selected_files):
|
|
23
|
+
"""
|
|
24
|
+
Matches images with centerline files.
|
|
25
|
+
Returns a list of subject IDs (filenames without extension).
|
|
26
|
+
"""
|
|
27
|
+
# Get all .img files
|
|
28
|
+
img_files = glob.glob(os.path.join(self.image_dir, "*.img"))
|
|
29
|
+
subjects = []
|
|
30
|
+
|
|
31
|
+
for img_path in img_files:
|
|
32
|
+
basename = os.path.basename(img_path)
|
|
33
|
+
subject_id = os.path.splitext(basename)[0]
|
|
34
|
+
|
|
35
|
+
# Check if matching centerline file exists
|
|
36
|
+
# Centerline naming convention seems to vary slightly based on file listing earlier.
|
|
37
|
+
# GT files: "01_Anonymous_0006_interp.xls" for "01_Anonymous_0006.img"?
|
|
38
|
+
# Let's verify with a flexible matching or assume "_interp.xls" suffix.
|
|
39
|
+
# Based on previous ls:
|
|
40
|
+
# Image: 01_Anonymous_0006.img
|
|
41
|
+
# GT: 01_Anonymous_0006_interp.xls
|
|
42
|
+
|
|
43
|
+
gt_path = os.path.join(self.centerline_dir, f"{subject_id}_interp.xls")
|
|
44
|
+
|
|
45
|
+
if os.path.exists(gt_path) and os.path.getsize(gt_path) > 0:
|
|
46
|
+
if selected_files is None or subject_id in selected_files:
|
|
47
|
+
subjects.append(subject_id)
|
|
48
|
+
else:
|
|
49
|
+
# Try exact match if suffix is different
|
|
50
|
+
gt_path_exact = os.path.join(self.centerline_dir, f"{subject_id}.xls")
|
|
51
|
+
if os.path.exists(gt_path_exact) and os.path.getsize(gt_path_exact) > 0:
|
|
52
|
+
if selected_files is None or subject_id in selected_files:
|
|
53
|
+
subjects.append(subject_id)
|
|
54
|
+
|
|
55
|
+
return sorted(subjects)
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def load_centerlines(gt_path):
|
|
59
|
+
"""Loads ground-truth rib centerlines from a tabular .xls file."""
|
|
60
|
+
return parse_rib_xls(gt_path)
|
|
61
|
+
|
|
62
|
+
def __len__(self):
|
|
63
|
+
return len(self.subjects)
|
|
64
|
+
|
|
65
|
+
def load_item(self, idx):
|
|
66
|
+
"""
|
|
67
|
+
Loads image and centerlines for a given index.
|
|
68
|
+
Returns:
|
|
69
|
+
image (sitk.Image): The 3D image.
|
|
70
|
+
centerlines (dict): Dictionary of centerline coordinates.
|
|
71
|
+
subject_id (str): Subject identifier.
|
|
72
|
+
"""
|
|
73
|
+
subject_id = self.subjects[idx]
|
|
74
|
+
img_path = os.path.join(self.image_dir, f"{subject_id}.img")
|
|
75
|
+
|
|
76
|
+
# Try both suffixes for GT
|
|
77
|
+
gt_path = os.path.join(self.centerline_dir, f"{subject_id}_interp.xls")
|
|
78
|
+
if not os.path.exists(gt_path):
|
|
79
|
+
gt_path = os.path.join(self.centerline_dir, f"{subject_id}.xls")
|
|
80
|
+
|
|
81
|
+
image = load_image(img_path)
|
|
82
|
+
centerlines = parse_rib_xls(gt_path)
|
|
83
|
+
|
|
84
|
+
return image, centerlines, subject_id
|
|
85
|
+
|
|
86
|
+
def generate_mask(self, image, centerlines):
|
|
87
|
+
"""
|
|
88
|
+
Generates a binary mask where 1 indicates rib centerline vicinity.
|
|
89
|
+
"""
|
|
90
|
+
# Create empty mask with same size as image
|
|
91
|
+
mask_arr = np.zeros(image.GetSize()[::-1], dtype=np.uint8) # SITK: x,y,z -> Numpy: z,y,x
|
|
92
|
+
|
|
93
|
+
spacing = image.GetSpacing() # sx, sy, sz
|
|
94
|
+
origin = image.GetOrigin()
|
|
95
|
+
|
|
96
|
+
for rib_name, points in centerlines.items():
|
|
97
|
+
for pt in points:
|
|
98
|
+
phys_pt = pt
|
|
99
|
+
idx_x = int((phys_pt[0] - origin[0]) / spacing[0])
|
|
100
|
+
idx_y = int((phys_pt[1] - origin[1]) / spacing[1])
|
|
101
|
+
idx_z = int((phys_pt[2] - origin[2]) / spacing[2])
|
|
102
|
+
|
|
103
|
+
avg_spacing = np.mean(spacing)
|
|
104
|
+
radius_vox = int(np.ceil(self.radius / avg_spacing))
|
|
105
|
+
|
|
106
|
+
z_dim, y_dim, x_dim = mask_arr.shape
|
|
107
|
+
|
|
108
|
+
for z in range(max(0, idx_z - radius_vox), min(z_dim, idx_z + radius_vox + 1)):
|
|
109
|
+
for y in range(max(0, idx_y - radius_vox), min(y_dim, idx_y + radius_vox + 1)):
|
|
110
|
+
for x in range(max(0, idx_x - radius_vox), min(x_dim, idx_x + radius_vox + 1)):
|
|
111
|
+
dist_sq = (x - idx_x)**2 + (y - idx_y)**2 + (z - idx_z)**2
|
|
112
|
+
if dist_sq <= radius_vox**2:
|
|
113
|
+
mask_arr[z, y, x] = 1
|
|
114
|
+
|
|
115
|
+
mask_img = sitk.GetImageFromArray(mask_arr)
|
|
116
|
+
mask_img.CopyInformation(image)
|
|
117
|
+
return mask_img
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def augment_item(self, image, centerlines):
|
|
121
|
+
"""
|
|
122
|
+
Applies random rigid transformation (Rotation + Translation) to image and centerlines.
|
|
123
|
+
"""
|
|
124
|
+
# Random Parameters
|
|
125
|
+
angle_z = np.radians(np.random.uniform(-15, 15))
|
|
126
|
+
tx = np.random.uniform(-10, 10) # mm
|
|
127
|
+
ty = np.random.uniform(-10, 10)
|
|
128
|
+
tz = np.random.uniform(-10, 10)
|
|
129
|
+
|
|
130
|
+
# Transform
|
|
131
|
+
transform = sitk.Euler3DTransform()
|
|
132
|
+
|
|
133
|
+
# Center of rotation (Image Center)
|
|
134
|
+
size = image.GetSize()
|
|
135
|
+
origin = image.GetOrigin()
|
|
136
|
+
spacing = image.GetSpacing()
|
|
137
|
+
center_idx = [sz/2.0 for sz in size]
|
|
138
|
+
# center_phys = origin + center_idx * spacing (element-wise)
|
|
139
|
+
center_phys = [origin[d] + center_idx[d]*spacing[d] for d in range(3)]
|
|
140
|
+
|
|
141
|
+
transform.SetCenter(center_phys)
|
|
142
|
+
transform.SetRotation(0, 0, angle_z)
|
|
143
|
+
transform.SetTranslation((tx, ty, tz))
|
|
144
|
+
|
|
145
|
+
# Resample Image
|
|
146
|
+
# Use bilinear or bspline? Linear is fast.
|
|
147
|
+
aug_image = sitk.Resample(image, image, transform, sitk.sitkLinear, 0.0, image.GetPixelID())
|
|
148
|
+
|
|
149
|
+
# Transform Centerlines
|
|
150
|
+
aug_centerlines = {}
|
|
151
|
+
for rib, pts in centerlines.items():
|
|
152
|
+
new_pts = []
|
|
153
|
+
for pt in pts:
|
|
154
|
+
# pt is (x,y,z) physical
|
|
155
|
+
# The transform maps Output Point -> Input Point for resampling.
|
|
156
|
+
# But for points, we want T(p).
|
|
157
|
+
# sitk.Resample(ref, transform) means: output(x) = input(transform(x)).
|
|
158
|
+
# So the transform describes "Where to look in input".
|
|
159
|
+
# This is the INVERSE of the coordinate transform if we think "Move the object".
|
|
160
|
+
# If we use transform.TransformPoint(pt), we are applying the "Sampling Transform".
|
|
161
|
+
# Wait.
|
|
162
|
+
# Resample: I_new(x) = I_old(T(x)).
|
|
163
|
+
# T maps New Space -> Old Space.
|
|
164
|
+
# If T is "Rotation +15 deg", then T(x) rotates x by +15.
|
|
165
|
+
# So I_new(x) gets value from I_old(x rotated).
|
|
166
|
+
# This means the content moves by -15 deg ??
|
|
167
|
+
# Let's verify.
|
|
168
|
+
# If I_new(0) = I_old(T(0)).
|
|
169
|
+
# This is standard "Pull back".
|
|
170
|
+
|
|
171
|
+
# For centerlines (Points), we want the points to match the NEW image content.
|
|
172
|
+
# The new image content at `x` corresponds to old content at `T(x)`.
|
|
173
|
+
# So if a feature was at `p_old` in old image, where is it in `p_new`?
|
|
174
|
+
# We want `p_old = T(p_new)`.
|
|
175
|
+
# So `p_new = T_inv(p_old)`.
|
|
176
|
+
|
|
177
|
+
# So we need the INVERSE transform for points.
|
|
178
|
+
|
|
179
|
+
p_inv = transform.GetInverse().TransformPoint(pt)
|
|
180
|
+
new_pts.append(p_inv)
|
|
181
|
+
aug_centerlines[rib] = np.array(new_pts)
|
|
182
|
+
|
|
183
|
+
return aug_image, aug_centerlines
|
|
184
|
+
|
|
185
|
+
def get_samples_from_data(self, image, centerlines, num_samples=1000):
|
|
186
|
+
"""
|
|
187
|
+
Samples points (indices) from the provided image/centerlines.
|
|
188
|
+
Used by get_training_samples and external callers.
|
|
189
|
+
"""
|
|
190
|
+
img_arr = sitk.GetArrayFromImage(image) # View might be risky if we modify? GetArray is copy.
|
|
191
|
+
# Actually we just need shape.
|
|
192
|
+
z_dim, y_dim, x_dim = img_arr.shape
|
|
193
|
+
spacing = image.GetSpacing()
|
|
194
|
+
origin = image.GetOrigin()
|
|
195
|
+
|
|
196
|
+
# 1. Positive Samples
|
|
197
|
+
pos_samples = []
|
|
198
|
+
all_rib_points = []
|
|
199
|
+
for r, pts in centerlines.items():
|
|
200
|
+
all_rib_points.extend(pts)
|
|
201
|
+
|
|
202
|
+
if not all_rib_points:
|
|
203
|
+
return [], []
|
|
204
|
+
|
|
205
|
+
# Sample random points from centerline
|
|
206
|
+
selected_indices = np.random.choice(len(all_rib_points), size=num_samples // 2, replace=True)
|
|
207
|
+
|
|
208
|
+
for i in selected_indices:
|
|
209
|
+
pt = all_rib_points[i]
|
|
210
|
+
|
|
211
|
+
# Physical -> Index
|
|
212
|
+
# We must use proper rounding or simple cast
|
|
213
|
+
idx_x = int((pt[0] - origin[0]) / spacing[0])
|
|
214
|
+
idx_y = int((pt[1] - origin[1]) / spacing[1])
|
|
215
|
+
idx_z = int((pt[2] - origin[2]) / spacing[2])
|
|
216
|
+
|
|
217
|
+
# Check bounds
|
|
218
|
+
if (0 <= idx_z < z_dim and
|
|
219
|
+
0 <= idx_y < y_dim and
|
|
220
|
+
0 <= idx_x < x_dim):
|
|
221
|
+
pos_samples.append(((idx_x, idx_y, idx_z), 1))
|
|
222
|
+
|
|
223
|
+
# 2. Negative Samples
|
|
224
|
+
neg_samples = []
|
|
225
|
+
|
|
226
|
+
count = 0
|
|
227
|
+
while count < num_samples // 2:
|
|
228
|
+
rz = np.random.randint(0, z_dim)
|
|
229
|
+
ry = np.random.randint(0, y_dim)
|
|
230
|
+
rx = np.random.randint(0, x_dim)
|
|
231
|
+
|
|
232
|
+
# TODO: Check collision with positive?
|
|
233
|
+
neg_samples.append(((rx, ry, rz), 0))
|
|
234
|
+
count += 1
|
|
235
|
+
|
|
236
|
+
return pos_samples, neg_samples
|
|
237
|
+
|
|
238
|
+
def get_training_samples(self, idx, num_samples=1000):
|
|
239
|
+
"""
|
|
240
|
+
Wrapper to load item and get samples (No augmentation by default).
|
|
241
|
+
"""
|
|
242
|
+
image, centerlines, _ = self.load_item(idx)
|
|
243
|
+
return self.get_samples_from_data(image, centerlines, num_samples)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import struct
|
|
3
|
+
import numpy as np
|
|
4
|
+
import SimpleITK as sitk
|
|
5
|
+
|
|
6
|
+
def read_custom_header(header_path):
|
|
7
|
+
"""
|
|
8
|
+
Reads the custom text-based header.
|
|
9
|
+
Format:
|
|
10
|
+
dim_x dim_y dim_z
|
|
11
|
+
spacing_x spacing_y spacing_z
|
|
12
|
+
min_val max_val (optional/ignored for loading)
|
|
13
|
+
"""
|
|
14
|
+
with open(header_path, 'r') as f:
|
|
15
|
+
lines = f.readlines()
|
|
16
|
+
|
|
17
|
+
if len(lines) < 2:
|
|
18
|
+
raise ValueError("Header too short")
|
|
19
|
+
|
|
20
|
+
dims = [int(x) for x in lines[0].strip().split()]
|
|
21
|
+
spacing = [float(x) for x in lines[1].strip().split()]
|
|
22
|
+
|
|
23
|
+
# We assume int16 based on file size analysis (2 bytes/pixel)
|
|
24
|
+
dtype = np.int16
|
|
25
|
+
|
|
26
|
+
# Analyze usually stores x, y, z.
|
|
27
|
+
# We return shape as (x, y, z) for consistency with previous logic,
|
|
28
|
+
# but remember numpy uses (z, y, x)
|
|
29
|
+
# We return origin as (0,0,0) because GT ignores the header offset
|
|
30
|
+
origin = (0.0, 0.0, 0.0)
|
|
31
|
+
return dims, spacing, dtype, origin
|
|
32
|
+
|
|
33
|
+
def read_analyze75_header(header_path):
|
|
34
|
+
"""
|
|
35
|
+
Reads the header of an Analyze 7.5 file (binary) or custom text header.
|
|
36
|
+
"""
|
|
37
|
+
# Try text first as we confirmed it for this dataset
|
|
38
|
+
try:
|
|
39
|
+
return read_custom_header(header_path)
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
with open(header_path, 'rb') as f:
|
|
44
|
+
header_data = f.read()
|
|
45
|
+
|
|
46
|
+
if len(header_data) < 348:
|
|
47
|
+
raise ValueError("Header too short for Analyze 7.5")
|
|
48
|
+
|
|
49
|
+
# Dim is at offset 40, 8 shorts (16 bytes)
|
|
50
|
+
dim = struct.unpack_from('<8h', header_data, 40)
|
|
51
|
+
|
|
52
|
+
# Pixdim is at offset 76, 8 floats (32 bytes)
|
|
53
|
+
pixdim = struct.unpack_from('<8f', header_data, 76)
|
|
54
|
+
|
|
55
|
+
# DataType is at offset 70 (short)
|
|
56
|
+
datatype_code = struct.unpack_from('<h', header_data, 70)[0]
|
|
57
|
+
|
|
58
|
+
dtype_map = {
|
|
59
|
+
2: np.uint8,
|
|
60
|
+
4: np.int16,
|
|
61
|
+
8: np.int32,
|
|
62
|
+
16: np.float32,
|
|
63
|
+
64: np.float64
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if datatype_code not in dtype_map:
|
|
67
|
+
# Fallback or error?
|
|
68
|
+
# If we reached here, text parse failed.
|
|
69
|
+
# Maybe it IS Big Endian?
|
|
70
|
+
datatype_code_be = struct.unpack_from('>h', header_data, 70)[0]
|
|
71
|
+
if datatype_code_be in dtype_map:
|
|
72
|
+
datatype_code = datatype_code_be
|
|
73
|
+
# We should switch to BE unpacking for everything, but let's assume LE for now
|
|
74
|
+
else:
|
|
75
|
+
raise ValueError(f"Unsupported Analyze 7.5 datatype code: {datatype_code}")
|
|
76
|
+
|
|
77
|
+
dtype = dtype_map[datatype_code]
|
|
78
|
+
|
|
79
|
+
ndim = dim[0]
|
|
80
|
+
shape = dim[1:ndim+1]
|
|
81
|
+
spacing = pixdim[1:ndim+1]
|
|
82
|
+
|
|
83
|
+
return shape, spacing, dtype, (0.0, 0.0, 0.0) # Binary fallback origin
|
|
84
|
+
|
|
85
|
+
def read_analyze75_numpy(path):
|
|
86
|
+
"""
|
|
87
|
+
Reads an Analyze 7.5 image pair (.img/.hdr) using numpy.
|
|
88
|
+
Args:
|
|
89
|
+
path (str): Path to .img or .hdr file.
|
|
90
|
+
Returns:
|
|
91
|
+
sitk.Image: SimpleITK Image object created from numpy array.
|
|
92
|
+
"""
|
|
93
|
+
base, ext = os.path.splitext(path)
|
|
94
|
+
if ext == '.gz':
|
|
95
|
+
base, ext2 = os.path.splitext(base)
|
|
96
|
+
ext = ext2 + ext
|
|
97
|
+
|
|
98
|
+
hdr_path = base + '.hdr'
|
|
99
|
+
img_path = base + '.img'
|
|
100
|
+
|
|
101
|
+
if not os.path.exists(hdr_path) or not os.path.exists(img_path):
|
|
102
|
+
raise FileNotFoundError(f"Analyze 7.5 pair not found: {hdr_path}, {img_path}")
|
|
103
|
+
|
|
104
|
+
shape, spacing, dtype, origin = read_analyze75_header(hdr_path)
|
|
105
|
+
|
|
106
|
+
# Determine total elements
|
|
107
|
+
num_elements = np.prod(shape)
|
|
108
|
+
|
|
109
|
+
# Read raw data
|
|
110
|
+
arr = np.fromfile(img_path, dtype=dtype)
|
|
111
|
+
|
|
112
|
+
if arr.size != num_elements:
|
|
113
|
+
raise ValueError(f"File size mismatch. Expected {num_elements} elements, got {arr.size}")
|
|
114
|
+
|
|
115
|
+
# Reshape: ITK expects [z, y, x] for numpy array access
|
|
116
|
+
# Shape from header is usually [x, y, z]
|
|
117
|
+
# We reshape to [z, y, x]
|
|
118
|
+
arr = arr.reshape(shape[::-1])
|
|
119
|
+
|
|
120
|
+
image = sitk.GetImageFromArray(arr)
|
|
121
|
+
image.SetSpacing(spacing)
|
|
122
|
+
image.SetOrigin((0.0, 0.0, 0.0)) # Force origin to 0 for this dataset
|
|
123
|
+
|
|
124
|
+
return image
|
|
125
|
+
|
|
126
|
+
def load_image(path):
|
|
127
|
+
"""
|
|
128
|
+
Robust image loader that attempts SimpleITK first, then falls back to custom Analyze reader.
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
return sitk.ReadImage(path)
|
|
132
|
+
except Exception as e:
|
|
133
|
+
if path.endswith('.img') or path.endswith('.hdr'):
|
|
134
|
+
try:
|
|
135
|
+
# print(f"SimpleITK failed, attempting custom Analyze 7.5 reader for {path}")
|
|
136
|
+
return read_analyze75_numpy(path)
|
|
137
|
+
except Exception as e2:
|
|
138
|
+
print(f"Custom reader also failed: {e2}")
|
|
139
|
+
raise e
|
|
140
|
+
raise e
|
|
File without changes
|