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.
Files changed (51) hide show
  1. ribline/__init__.py +57 -0
  2. ribline/configs/__init__.py +0 -0
  3. ribline/configs/config_schema.json +48 -0
  4. ribline/configs/rib_atlas.joblib +0 -0
  5. ribline/data/__init__.py +0 -0
  6. ribline/data/centerline_parser.py +39 -0
  7. ribline/data/dataset.py +243 -0
  8. ribline/data/image_reader.py +140 -0
  9. ribline/model/__init__.py +0 -0
  10. ribline/model/anatomy.py +701 -0
  11. ribline/model/features.py +273 -0
  12. ribline/model/forest.py +48 -0
  13. ribline/model/metrics.py +420 -0
  14. ribline/model/reconstruction.py +422 -0
  15. ribline/model/refinement.py +452 -0
  16. ribline/model/shape_prior.py +5 -0
  17. ribline/model/topology.py +570 -0
  18. ribline/model/unet/__init__.py +0 -0
  19. ribline/model/unet/dataset.py +151 -0
  20. ribline/model/unet/loss.py +36 -0
  21. ribline/model/unet/model.py +93 -0
  22. ribline/pipeline/__init__.py +0 -0
  23. ribline/pipeline/evaluate.py +196 -0
  24. ribline/pipeline/evaluate_single.py +62 -0
  25. ribline/pipeline/inference.py +225 -0
  26. ribline/pipeline/inference_pass2.py +202 -0
  27. ribline/pipeline/inference_unet.py +171 -0
  28. ribline/pipeline/pipeline_sota.py +216 -0
  29. ribline/pipeline/refine_centerlines.py +84 -0
  30. ribline/pipeline/train.py +126 -0
  31. ribline/pipeline/train_pass2.py +152 -0
  32. ribline/pipeline/train_unet.py +180 -0
  33. ribline/scripts/analyze_rib_lengths.py +84 -0
  34. ribline/scripts/benchmark_eval.py +290 -0
  35. ribline/scripts/build_atlas.py +63 -0
  36. ribline/scripts/correct_gt.py +176 -0
  37. ribline/scripts/fix_gt_annotations.py +116 -0
  38. ribline/scripts/polar_angle_stats.py +135 -0
  39. ribline/scripts/run_ablation.py +92 -0
  40. ribline/tests/__init__.py +0 -0
  41. ribline/tests/test_anatomy_and_topology.py +229 -0
  42. ribline/tests/test_data_loading.py +161 -0
  43. ribline/tests/test_metrics.py +123 -0
  44. ribline/tests/test_train_flow.py +86 -0
  45. ribline/utils/__init__.py +0 -0
  46. ribline-0.3.0.dist-info/METADATA +342 -0
  47. ribline-0.3.0.dist-info/RECORD +51 -0
  48. ribline-0.3.0.dist-info/WHEEL +5 -0
  49. ribline-0.3.0.dist-info/entry_points.txt +3 -0
  50. ribline-0.3.0.dist-info/licenses/LICENSE +7 -0
  51. 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
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 {}
@@ -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