|
a |
|
b/CLI/CancellousAnalysis/CancellousAnalysis.py |
|
|
1 |
#!/usr/bin/env python-real |
|
|
2 |
|
|
|
3 |
import sys |
|
|
4 |
import os |
|
|
5 |
from datetime import date |
|
|
6 |
|
|
|
7 |
import numpy as np |
|
|
8 |
|
|
|
9 |
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
|
|
10 |
from MusculoskeletalAnalysisCLITools import ( |
|
|
11 |
crop, |
|
|
12 |
bWshape, |
|
|
13 |
densityMap, |
|
|
14 |
findSpheres, |
|
|
15 |
readImg, |
|
|
16 |
readMask, |
|
|
17 |
updateVertices, |
|
|
18 |
writeReport, |
|
|
19 |
) |
|
|
20 |
|
|
|
21 |
|
|
|
22 |
OUTPUT_FIELDS = [ |
|
|
23 |
("Date Analysis Performed", "The current date"), |
|
|
24 |
("Input Volume", "Name of the input volume"), |
|
|
25 |
("Total Volume (mm^3)", "The volume of the segmented area"), |
|
|
26 |
("Bone Volume (mm^3)", "The volume of cancellous bone in the segmented area, calculated using marching cubes"), |
|
|
27 |
("Bone Volume/Total Volume", "The fraction of the volume that is bone"), |
|
|
28 |
("Mean Trabecular Thickness (mm)", "The mean thickess of the bone, measured using largest sphere thickness, in milimeters"), |
|
|
29 |
("Trabecular Thickness Standard Deviation (mm)", "The standard deviation of the mean trabecular thickness"), |
|
|
30 |
("Mean Trabecular Spacing (mm)", "The mean thickness of the non area not containing bone in milimeters, measured using the same method as bone thickness."), |
|
|
31 |
("Trabecular Spacing Standard Deviation (mm)", "The standard deviation of the mean trabecular spacing"), |
|
|
32 |
("Trabecular Number", "Approximated as inverse of trabecular spacing"), |
|
|
33 |
("Structure Model Index", "A measurement of the trabecular shape. 0 is a plate, 3 is a rod, 4 is a sphere"), |
|
|
34 |
("Connectivity Density", "A measurement of the number of connections per volume, based on the Euler characteristic of the bone after removing isolated components and holes"), |
|
|
35 |
("Tissue Mineral Density(mgHA/cm^3)", "The mean density of the bone, measured in miligrams of hydroxyapatite per cubic centimeter"), |
|
|
36 |
("Voxel Dimension (mm)", "The side length of one voxel, measured in milimeters"), |
|
|
37 |
("Lower Threshold", "The lower threshold value for bone"), |
|
|
38 |
("Upper Threshold", "The upper threshold value for bone"), |
|
|
39 |
] |
|
|
40 |
|
|
|
41 |
|
|
|
42 |
# Performs analysis on cancellous bone |
|
|
43 |
# image: 3D image black and white of bone |
|
|
44 |
# mask: 3D labelmap of bone area, including cavities |
|
|
45 |
# threshold: The threshold for bone in the image. Any pixels with a higher value will be considered bone. |
|
|
46 |
# voxSize: The physical side length of the voxels, in mm |
|
|
47 |
# slope, intercept and scale: parameters for density conversion |
|
|
48 |
# output: The name of the output directory |
|
|
49 |
def main(inputImg, inputMask, lower, upper, voxSize, slope, intercept, name, output): |
|
|
50 |
from skimage import measure |
|
|
51 |
|
|
|
52 |
imgData = readImg(inputImg) |
|
|
53 |
(_, maskData) = readMask(inputMask) |
|
|
54 |
|
|
|
55 |
(maskData, imgData) = crop(maskData, imgData) |
|
|
56 |
if np.count_nonzero(maskData) == 0: |
|
|
57 |
raise Exception("Segmentation mask is empty.") |
|
|
58 |
trabecular = (imgData > lower) & (imgData <= upper) |
|
|
59 |
# Create mesh using marching squares and calculate its volume |
|
|
60 |
boneMesh = bWshape(imgData, lower) |
|
|
61 |
boneVolume=boneMesh.volume * voxSize**3 |
|
|
62 |
# Find volume of entire mask by counting voxels |
|
|
63 |
totalVolume = np.count_nonzero(maskData) * voxSize**3 |
|
|
64 |
bvtv = boneVolume/totalVolume |
|
|
65 |
print("""<filter-progress>{}</filter-progress>""".format(.20)) |
|
|
66 |
sys.stdout.flush() |
|
|
67 |
background = np.bitwise_and(maskData, np.invert(trabecular)) |
|
|
68 |
# Get the thickness map and calculate the thickness |
|
|
69 |
rads = findSpheres(trabecular) |
|
|
70 |
diams = rads * 2 * voxSize |
|
|
71 |
thickness = np.mean(diams) |
|
|
72 |
thicknessStd = np.std(diams) |
|
|
73 |
print("""<filter-progress>{}</filter-progress>""".format(.40)) |
|
|
74 |
sys.stdout.flush() |
|
|
75 |
# Get the thickness map for the background |
|
|
76 |
rads = findSpheres(background) |
|
|
77 |
diams = rads * 2 * voxSize |
|
|
78 |
spacing = np.mean(diams) |
|
|
79 |
spacingStd = np.std(diams) |
|
|
80 |
trabecularNum = 1/spacing |
|
|
81 |
print("""<filter-progress>{}</filter-progress>""".format(.60)) |
|
|
82 |
sys.stdout.flush() |
|
|
83 |
# SMI |
|
|
84 |
# Calculated by finding a 3d mesh, expanding the vertices by a small amount, and calculate a value based on the relative difference in surface area |
|
|
85 |
# Measures the characteristics of the shape; 0 for plate-like, 3 for rod-like, 4 for sphere-like |
|
|
86 |
# Surface area |
|
|
87 |
bS = measure.mesh_surface_area(boneMesh.vertices, boneMesh.faces)*(voxSize**2) |
|
|
88 |
# Arbitrary small value. Reducing size increases accuracy, but risks being rounded to zero |
|
|
89 |
dr = 0.000001 |
|
|
90 |
# Creates a new mesh by moving each vertex a small distance in the direction of its vertex normal, then find the difference in surface area |
|
|
91 |
newVert=np.add(boneMesh.vertices, boneMesh.vertex_normals*dr) |
|
|
92 |
boneMesh = updateVertices(boneMesh, newVert) |
|
|
93 |
dS = (measure.mesh_surface_area(boneMesh.vertices, boneMesh.faces)*(voxSize**2))-bS |
|
|
94 |
# Convert to mm |
|
|
95 |
dr = dr*voxSize |
|
|
96 |
# Apply the SMI formula |
|
|
97 |
SMI=(6*boneVolume*(dS/dr)/(bS**2)) |
|
|
98 |
print("""<filter-progress>{}</filter-progress>""".format(.80)) |
|
|
99 |
sys.stdout.flush() |
|
|
100 |
# Calculate density |
|
|
101 |
density = densityMap(imgData, slope, intercept) |
|
|
102 |
tmd = np.mean(density[trabecular]) |
|
|
103 |
|
|
|
104 |
# Connnectivity |
|
|
105 |
# Remove disconected holes and islands |
|
|
106 |
labelmap=measure.label(np.invert(trabecular), connectivity=1) |
|
|
107 |
largest=np.argmax(np.bincount(labelmap[np.nonzero(labelmap)])) |
|
|
108 |
trabecular=labelmap!=largest |
|
|
109 |
# Holes use face connectivity, switch to vertex connectivity for islands |
|
|
110 |
labelmap=measure.label(trabecular, connectivity=3) |
|
|
111 |
largest=np.argmax(np.bincount(labelmap[np.nonzero(labelmap)])) |
|
|
112 |
trabecular=labelmap==largest |
|
|
113 |
|
|
|
114 |
# Find the euler characteristic |
|
|
115 |
# roughly equal to 2-2*number of holes |
|
|
116 |
phi = measure.euler_number(trabecular, connectivity=3) |
|
|
117 |
# connD gives an approximate measure of holes/connections per volume |
|
|
118 |
connD = (1-phi)/totalVolume |
|
|
119 |
|
|
|
120 |
|
|
|
121 |
|
|
|
122 |
|
|
|
123 |
fPath = os.path.join(output, "cancellous.txt") |
|
|
124 |
|
|
|
125 |
header = [field[0] for field in OUTPUT_FIELDS] |
|
|
126 |
|
|
|
127 |
data = [ |
|
|
128 |
date.today(), |
|
|
129 |
name, |
|
|
130 |
totalVolume, |
|
|
131 |
boneVolume, |
|
|
132 |
bvtv, |
|
|
133 |
thickness, |
|
|
134 |
thicknessStd, |
|
|
135 |
spacing, |
|
|
136 |
spacingStd, |
|
|
137 |
trabecularNum, |
|
|
138 |
SMI, |
|
|
139 |
connD, |
|
|
140 |
tmd, |
|
|
141 |
voxSize, |
|
|
142 |
lower, |
|
|
143 |
upper |
|
|
144 |
] |
|
|
145 |
|
|
|
146 |
writeReport(fPath, header, data) |
|
|
147 |
|
|
|
148 |
if __name__ == "__main__": |
|
|
149 |
if len(sys.argv) < 10: |
|
|
150 |
print(sys.argv) |
|
|
151 |
print(len(sys.argv)) |
|
|
152 |
print("Usage: CancellousAnalysis <input> <mask> <lowerThreshold> <upperThreshold> <voxelSize> <slope> <intercept> <name> <output>") |
|
|
153 |
sys.exit(1) |
|
|
154 |
|
|
|
155 |
main(sys.argv[1], sys.argv[2], float(sys.argv[3]), float(sys.argv[4]), float(sys.argv[5]), float(sys.argv[6]), float(sys.argv[7]), str(sys.argv[8]), str(sys.argv[9])) |