dicoms 1.0.0 → 1.2.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 3f1e0d49c4679975f1fcecf31adc7315f911b94a
4
- data.tar.gz: 6c3646e777ca6bed2d63b1f984635e2711613a24
3
+ metadata.gz: 280aba2d5fdbf5adf3b30d288486c7c756df21e0
4
+ data.tar.gz: fa01e90238b33269969363cf4142e9314b4a6873
5
5
  SHA512:
6
- metadata.gz: a9a0df47f50b523b2097186a1506edde051042c104f1a4af9b0fe34fb4d9b90852ec04dfcda823ecf884b8de30b3451e48ef0f174c9bdd8c138b083e30b68fcc
7
- data.tar.gz: 308aa9e1db3fce6102d8b335adf7147d7ad43b39eef53ffa740039504de07e6ce8a9d085d9d87055fb755936e24b26e0577fa864e951cd7abb5ef5b1ccc7fb19
6
+ metadata.gz: 7aad5d1d1ab092b05cddc40b00f04be771b4335380b5e3b32576ed97ccdf5f1ba426f7dc416db36f3f099fbb466959d983caaf87f507370e9828fd97baf7698b
7
+ data.tar.gz: 03e9f692beed4c588ee7f83ca844448a041a2da9eb3453857396aadf6c2e748c18f04c73a4d3698d1ee2d55a7e3107220d4505fffbb061364dd799ca65681f05
data/LICENSE.md CHANGED
@@ -1,5 +1,3 @@
1
- Copyright (c) 2015 Javier Goizueta - All Rights Reserved
2
-
3
1
  GNU GENERAL PUBLIC LICENSE
4
2
  ==========================
5
3
 
@@ -25,7 +25,8 @@ Gem::Specification.new do |spec|
25
25
  spec.add_dependency 'modalsettings', '~> 1.0.1'
26
26
  spec.add_dependency 'narray', '~> 0.6'
27
27
  spec.add_dependency 'thor', '~> 0.19'
28
-
28
+ spec.add_dependency 'solver', '>= 0.2.0'
29
+ spec.add_dependency 'histogram', '~> 0.2.4'
29
30
 
30
31
  spec.add_development_dependency "bundler", "~> 1.10"
31
32
  spec.add_development_dependency "rake", "~> 10.0"
@@ -19,6 +19,10 @@ require "dicoms/unpack"
19
19
  require "dicoms/stats"
20
20
  require "dicoms/projection"
21
21
  require "dicoms/remap"
22
+ require "dicoms/explode"
23
+ require "dicoms/sigmoid"
24
+ require "dicoms/info"
25
+ require "dicoms/histogram"
22
26
 
23
27
  class DicomS
24
28
 
@@ -1,6 +1,27 @@
1
1
  require 'thor'
2
2
 
3
3
  class DicomS
4
+
5
+ def self.handle_errors(options)
6
+ return yield if options.debug
7
+ options = CommandOptions[options]
8
+ progress = Progress.new(nil, options)
9
+ if progress.persistent?
10
+ begin
11
+ yield
12
+ 0
13
+ rescue Error => error
14
+ progress.error! error.code, error.to_s
15
+ 1
16
+ rescue => error
17
+ progress.error! 'unknown', error.to_s
18
+ 2
19
+ end
20
+ else
21
+ yield
22
+ end
23
+ end
24
+
4
25
  class CLI < Thor
5
26
  check_unknown_options!
6
27
 
@@ -17,6 +38,7 @@ class DicomS
17
38
  class_option 'verbose', type: :boolean, default: false
18
39
  class_option 'settings', type: :string, desc: 'settings (read-only) file'
19
40
  class_option 'settings_io', type: :string, desc: 'settings file'
41
+ class_option 'debug', type: :boolean, default: false
20
42
 
21
43
  desc "pack DICOM-DIR", "pack a DICOM directory"
22
44
  option :output, desc: 'output file', aliases: '-o'
@@ -35,22 +57,23 @@ class DicomS
35
57
  ignore_min: true
36
58
  }
37
59
  settings = {} # TODO: ...
38
- unless File.directory?(dicom_dir)
39
- raise Error, set_color("Directory not found: #{dicom_dir}", :red)
40
- say options
41
- end
42
60
  cmd_options = CommandOptions[
43
61
  settings: options.settings,
44
62
  settings_io: options.settings_io,
45
63
  output: options.output,
46
64
  tmp: options.tmp,
47
65
  reorder: options.reorder,
48
- dicom_metadata: true
66
+ dicom_metadata: true,
67
+ debug: options.debug
49
68
  ]
50
- packer = DicomS.new(settings)
51
- packer.pack dicom_dir, cmd_options
52
- # rescue => raise Error?
53
- 0
69
+ DicomS.handle_errors(cmd_options) do
70
+ packer = DicomS.new(settings)
71
+ unless File.directory?(dicom_dir)
72
+ raise Error, set_color("Directory not found: #{dicom_dir}", :red)
73
+ say options
74
+ end
75
+ packer.pack dicom_dir, cmd_options
76
+ end
54
77
  end
55
78
 
56
79
  desc "unpack dspack", "unpack a dspack file"
@@ -59,20 +82,22 @@ class DicomS
59
82
  # TODO: parameters for dicom regeneration
60
83
  def unpack(dspack)
61
84
  DICOM.logger.level = Logger::FATAL
62
- unless File.file?(dspack)
63
- raise Error, set_color("File not found: #{dspack}", :red)
64
- say options
65
- end
66
85
  settings = {} # TODO: ...
67
- packer = DicomS.new(settings)
68
- packer.unpack(
69
- dspack,
86
+ cmd_options = {
70
87
  settings: options.settings,
71
88
  settings_io: options.settings_io,
72
89
  output: options.output,
73
- dicom_output: options.dicom
74
- )
75
- # rescue => raise Error?
90
+ dicom_output: options.dicom,
91
+ debug: options.debug
92
+ }
93
+ DicomS.handle_errors(cmd_options) do
94
+ packer = DicomS.new(settings)
95
+ unless File.file?(dspack)
96
+ raise Error, set_color("File not found: #{dspack}", :red)
97
+ say options
98
+ end
99
+ packer.unpack dspack, cmd_options
100
+ end
76
101
  0
77
102
  end
78
103
 
@@ -91,10 +116,6 @@ class DicomS
91
116
  def extract(dicom_dir)000
92
117
  DICOM.logger.level = Logger::FATAL
93
118
  settings = {} # TODO: ...
94
- unless File.exists?(dicom_dir)
95
- raise Error, set_color("Directory not found: #{dicom_dir}", :red)
96
- say options
97
- end
98
119
 
99
120
  raw = options.raw
100
121
  if options.big
@@ -105,15 +126,20 @@ class DicomS
105
126
  little_endian = true
106
127
  end
107
128
 
108
- packer = DicomS.new(settings)
109
- packer.extract(
110
- dicom_dir,
129
+ cmd_options = {
111
130
  transfer: DicomS.transfer_options(options),
112
131
  output: options.output,
113
- raw: raw, big_endian: big_endian, little_endian: little_endian
114
- )
115
- # rescue => raise Error?
116
- 0
132
+ raw: raw, big_endian: big_endian, little_endian: little_endian,
133
+ debug: options.debug
134
+ }
135
+ DicomS.handle_errors(cmd_options) do
136
+ packer = DicomS.new(settings)
137
+ unless File.exists?(dicom_dir)
138
+ raise Error, set_color("Directory not found: #{dicom_dir}", :red)
139
+ say options
140
+ end
141
+ packer.extract(dicom_dir, cmd_options)
142
+ end
117
143
  end
118
144
 
119
145
  desc "Level stats", "Level limits of one or more DICOM files"
@@ -126,6 +152,31 @@ class DicomS
126
152
  puts " Minimum level: #{stats[:min]}"
127
153
  puts " Next minimum level: #{stats[:next_min]}"
128
154
  puts " Maximum level: #{stats[:max]}"
155
+ puts "Histogram:"
156
+ dicoms.print_histogram *stats[:histogram], compact: true
157
+ 0
158
+ end
159
+
160
+ desc "Histogram", "Histogram of one or more DICOM files"
161
+ option :width, desc: 'bin width', aliases: '-w'
162
+ option :compact, desc: 'compact format', aliases: '-c'
163
+ def histogram(dicom_dir)
164
+ DICOM.logger.level = Logger::FATAL
165
+ settings = {} # TODO: ...
166
+ dicoms = DicomS.new(settings)
167
+ width = options.width && options.width.to_f
168
+ compact = !!options.compact
169
+ dicoms.histogram dicom_dir, bin_width: width, compact: compact
170
+ 0
171
+ end
172
+
173
+ desc "Information", "Show DICOM metadata"
174
+ option :output, desc: 'output directory or file', aliases: '-o'
175
+ def info(dicom_dir)
176
+ DICOM.logger.level = Logger::FATAL
177
+ settings = {} # TODO: ...
178
+ dicoms = DicomS.new(settings)
179
+ dicoms.info dicom_dir, output: options.output
129
180
  0
130
181
  end
131
182
 
@@ -145,14 +196,14 @@ class DicomS
145
196
  option :max_x_pixels, desc: 'maximum number of pixels in the X direction'
146
197
  option :max_y_pixels, desc: 'maximum number of pixels in the Y direction'
147
198
  option :max_z_pixels, desc: 'maximum number of pixels in the Z direction'
199
+ option :maxcols, desc: 'maximum number of image columns'
200
+ option :maxrows, desc: 'maximum number of image rows'
201
+ option :mincols, desc: 'minimum number of image columns'
202
+ option :minrows, desc: 'minimum number of image rows'
148
203
  option :reorder, desc: 'reorder slices based on instance number'
149
204
  def projection(dicom_dir)
150
205
  DICOM.logger.level = Logger::FATAL
151
206
  settings = {} # TODO: ...
152
- unless File.directory?(dicom_dir)
153
- raise Error, set_color("Directory not found: #{dicom_dir}", :red)
154
- say options
155
- end
156
207
  if options.settings_io || options.settings
157
208
  cmd_options = CommandOptions[
158
209
  settings: options.settings,
@@ -161,7 +212,12 @@ class DicomS
161
212
  max_x_pixels: options.max_x_pixels && options.max_x_pixels.to_i,
162
213
  max_y_pixels: options.max_y_pixels && options.max_y_pixels.to_i,
163
214
  max_z_pixels: options.max_z_pixels && options.max_z_pixels.to_i,
215
+ maxrows: options.maxrows && options.maxrows.to_i,
216
+ maxcols: options.maxcols && options.maxcols.to_i,
217
+ minrows: options.minrows && options.minrows.to_i,
218
+ mincols: options.mincols && options.mincols.to_i,
164
219
  reorder: options.reorder,
220
+ debug: options.debug
165
221
  ]
166
222
  else
167
223
  cmd_options = CommandOptions[
@@ -173,16 +229,84 @@ class DicomS
173
229
  max_x_pixels: options.max_x_pixels && options.max_x_pixels.to_i,
174
230
  max_y_pixels: options.max_y_pixels && options.max_y_pixels.to_i,
175
231
  max_z_pixels: options.max_z_pixels && options.max_z_pixels.to_i,
232
+ maxrows: options.maxrows && options.maxrows.to_i,
233
+ maxcols: options.maxcols && options.maxcols.to_i,
234
+ minrows: options.minrows && options.minrows.to_i,
235
+ mincols: options.mincols && options.mincols.to_i,
176
236
  reorder: options.reorder,
237
+ debug: options.debug
177
238
  ]
178
239
  end
179
- unless cmd_options.axial || options.sagittal || options.coronal
180
- raise Error, "Must specify at least one projection (axial/sagittal/coronal)"
240
+ DicomS.handle_errors(cmd_options) do
241
+ unless File.directory?(dicom_dir)
242
+ raise Error, set_color("Directory not found: #{dicom_dir}", :red)
243
+ say options
244
+ end
245
+ unless cmd_options.axial || options.sagittal || options.coronal
246
+ raise Error, "Must specify at least one projection (axial/sagittal/coronal)"
247
+ end
248
+ packer = DicomS.new(settings)
249
+ packer.projection(dicom_dir, cmd_options)
250
+ end
251
+ end
252
+
253
+ desc "explode DICOM-DIR", "extract all projected images from a DICOM sequence"
254
+ option :output, desc: 'output directory', aliases: '-o'
255
+ option :transfer, desc: 'transfer method', aliases: '-t', default: 'window'
256
+ # TODO: add :mip_transfer
257
+ # option :byte, desc: 'transfer as bytes', aliases: '-b'
258
+ option :center, desc: 'center (window transfer)', aliases: '-c'
259
+ option :width, desc: 'window (window transfer)', aliases: '-w'
260
+ option :ignore_min, desc: 'ignore minimum (global/first/sample transfer)', aliases: '-i'
261
+ option :samples, desc: 'number of samples (sample transfer)', aliases: '-s'
262
+ option :min, desc: 'minimum value (fixed transfer)'
263
+ option :max, desc: 'maximum value (fixed transfer)'
264
+ option :max_x_pixels, desc: 'maximum number of pixels in the X direction'
265
+ option :max_y_pixels, desc: 'maximum number of pixels in the Y direction'
266
+ option :max_z_pixels, desc: 'maximum number of pixels in the Z direction'
267
+ option :maxcols, desc: 'maximum number of image columns'
268
+ option :maxrows, desc: 'maximum number of image rows'
269
+ option :mincols, desc: 'minimum number of image columns'
270
+ option :minrows, desc: 'minimum number of image rows'
271
+ option :reorder, desc: 'reorder slices based on instance number'
272
+ option :no_axial, desc: 'omit axial slices extraction', type: :boolean, default: false
273
+ option :no_coronal, desc: 'omit coronal slices extraction', type: :boolean, default: false
274
+ option :no_sagittal, desc: 'omit sagittal slices extraction', type: :boolean, default: false
275
+ option :no_mip, desc: 'omit mip projection', type: :boolean, default: false
276
+ option :no_aap, desc: 'omit aap projection', type: :boolean, default: false
277
+ def explode(dicom_dir)
278
+ DICOM.logger.level = Logger::FATAL
279
+ settings = {} # TODO: ...
280
+ unless File.directory?(dicom_dir)
281
+ raise Error, set_color("Directory not found: #{dicom_dir}", :red)
282
+ say options
283
+ end
284
+ cmd_options = CommandOptions[
285
+ settings: options.settings,
286
+ settings_io: options.settings_io,
287
+ output: options.output,
288
+ max_x_pixels: options.max_x_pixels && options.max_x_pixels.to_i,
289
+ max_y_pixels: options.max_y_pixels && options.max_y_pixels.to_i,
290
+ max_z_pixels: options.max_z_pixels && options.max_z_pixels.to_i,
291
+ maxrows: options.maxrows && options.maxrows.to_i,
292
+ maxcols: options.maxcols && options.maxcols.to_i,
293
+ minrows: options.minrows && options.minrows.to_i,
294
+ mincols: options.mincols && options.mincols.to_i,
295
+ reorder: options.reorder,
296
+ no_axial: options.no_axial,
297
+ no_coronal: options.no_coronal,
298
+ no_sagittal: options.no_sagittal,
299
+ no_mip: options.no_mip,
300
+ no_aap: options.no_aap,
301
+ debug: options.debug
302
+ ]
303
+ unless options.settings_io || options.settings
304
+ cmd_options.merge! transfer: DicomS.transfer_options(options)
305
+ end
306
+ DicomS.handle_errors(cmd_options) do
307
+ packer = DicomS.new(settings)
308
+ packer.explode(dicom_dir, cmd_options)
181
309
  end
182
- packer = DicomS.new(settings)
183
- packer.projection(dicom_dir, cmd_options)
184
- # rescue => raise Error?
185
- 0
186
310
  end
187
311
 
188
312
  desc "Remap DICOM-DIR", "convert DICOM pixel values"
@@ -199,18 +323,19 @@ class DicomS
199
323
  def remap(dicom_dir)
200
324
  DICOM.logger.level = Logger::FATAL
201
325
  settings = {} # TODO: ...
202
- unless File.directory?(dicom_dir)
203
- raise Error, set_color("Directory not found: #{dicom_dir}", :red)
204
- say options
205
- end
206
- packer = DicomS.new(settings)
207
- packer.remap(
208
- dicom_dir,
326
+ cmd_options = {
209
327
  transfer: DicomS.transfer_options(options),
210
- output: options.output
211
- )
212
- # rescue => raise Error?
213
- 0
328
+ output: options.output,
329
+ debug: options.debug
330
+ }
331
+ DicomS.handle_errors(cmd_options) do
332
+ unless File.directory?(dicom_dir)
333
+ raise Error, set_color("Directory not found: #{dicom_dir}", :red)
334
+ say options
335
+ end
336
+ packer = DicomS.new(settings)
337
+ packer.remap(dicom_dir, cmd_options)
338
+ end
214
339
  end
215
340
  end
216
341
 
@@ -0,0 +1,421 @@
1
+ require 'rmagick'
2
+
3
+ class DicomS
4
+ # Extract all projected views (all slices in the three axis,
5
+ # plus aap and mip projectios)
6
+ def explode(dicom_directory, options = {})
7
+ options = CommandOptions[options]
8
+
9
+ progress = Progress.new('projecting', options)
10
+ progress.begin_subprocess 'reading_metadata', 1
11
+
12
+ # Create sequence without a transfer strategy
13
+ # (identity strategy with rescaling would also do)
14
+ # so that we're working with Hounsfield units
15
+ sequence = Sequence.new(
16
+ dicom_directory,
17
+ reorder: options[:reorder]
18
+ )
19
+
20
+ slice_transfer = define_transfer(
21
+ options,
22
+ :window,
23
+ output: :unsigned
24
+ )
25
+ mip_transfer = define_transfer(
26
+ { transfer: options[:mip_transfer] },
27
+ :fixed,
28
+ min: -1000, max: 2000,
29
+ output: :unsigned
30
+ )
31
+ aap_transfer = Transfer.strategy :fixed, min: 0.0, max: 1.0, float: true, output: :unsigned
32
+
33
+ extract_dir = options.path_option(
34
+ :output, File.join(File.expand_path(dicom_directory), 'images')
35
+ )
36
+ FileUtils.mkdir_p extract_dir
37
+
38
+ if sequence.metadata.lim_max <= 255
39
+ bits = 8
40
+ else
41
+ bits = 16
42
+ end
43
+
44
+ scaling = projection_scaling(sequence.metadata, options)
45
+ sequence.metadata.merge! scaling
46
+ scaling = Settings[scaling]
47
+
48
+ reverse_x = sequence.metadata.reverse_x.to_i == 1
49
+ reverse_y = sequence.metadata.reverse_y.to_i == 1
50
+ reverse_z = sequence.metadata.reverse_z.to_i == 1
51
+
52
+ maxx = sequence.metadata.nx
53
+ maxy = sequence.metadata.ny
54
+ maxz = sequence.metadata.nz
55
+
56
+ # minimum and maximum slices with non-(almost)-blank contents
57
+ minx_contents = maxx
58
+ maxx_contents = 0
59
+ miny_contents = maxy
60
+ maxy_contents = 0
61
+ minz_contents = maxz
62
+ maxz_contents = 0
63
+
64
+ axial_zs = options.no_axial ? [] : (0...maxz)
65
+ sagittal_xs = options.no_sagittal ? [] : (0...maxx)
66
+ coronal_ys = options.no_coronal ? [] : (0...maxy)
67
+
68
+ # Will determine first and last slice with noticeable contents in each axis
69
+ # Slices outside the range won't be generated to save space
70
+ # This information will also be used for the app projection
71
+ minx_contents = maxx
72
+ maxx_contents = 0
73
+ miny_contents = maxy
74
+ maxy_contents = 0
75
+ minz_contents = maxz
76
+ maxz_contents = 0
77
+
78
+ n_slices = axial_zs.size + sagittal_xs.size + coronal_ys.size
79
+ n_projections = 0
80
+ n_projections += 3 unless options.no_mip
81
+ n_projections += 3 unless options.no_aap
82
+
83
+ progress.begin_subprocess 'generating_volume', 50, maxz
84
+
85
+ # Load all the slices into a floating point 3D array
86
+ volume = NArray.sfloat(maxx, maxy, maxz)
87
+ keeping_path do
88
+ sequence.each do |dicom, z, file|
89
+ slice = sequence.dicom_pixels(dicom)
90
+ volume[true, true, z] = slice
91
+ progress.update_subprocess z
92
+ end
93
+ end
94
+
95
+ maxv = volume.max
96
+
97
+ # Generate slices
98
+ slices_percent = n_projections > 0 ? -70 : 100
99
+ progress.begin_subprocess 'generating_slices', slices_percent, n_slices if n_slices > 0
100
+ axial_zs.each_with_index do |z, i|
101
+ slice = volume[true, true, z]
102
+ minz_contents, maxz_contents = update_min_max_contents(
103
+ z, slice.max, maxv, minz_contents, maxz_contents
104
+ )
105
+ next unless (minz_contents..maxz_contents).include?(z)
106
+ output_image = output_file_name(extract_dir, 'axial_', z.to_s)
107
+ save_transferred_pixels sequence, slice_transfer, slice, output_image,
108
+ bit_depth: bits, reverse_x: reverse_x, reverse_y: reverse_y,
109
+ cols: scaling.scaled_nx, rows: scaling.scaled_ny,
110
+ normalize: false
111
+ progress.update_subprocess i
112
+ end
113
+ sagittal_xs.each_with_index do |x, i|
114
+ slice = volume[x, true, true]
115
+ minx_contents, maxx_contents = update_min_max_contents(
116
+ x, slice.max, maxv, minx_contents, maxx_contents
117
+ )
118
+ next unless (minx_contents..maxx_contents).include?(x)
119
+ output_image = output_file_name(extract_dir, 'sagittal_', x.to_s)
120
+ save_transferred_pixels sequence, slice_transfer, slice, output_image,
121
+ bit_depth: bits, reverse_x: !reverse_y, reverse_y: !reverse_z,
122
+ cols: scaling.scaled_ny, rows: scaling.scaled_nz,
123
+ normalize: false
124
+ progress.update_subprocess axial_zs.size + i
125
+ end
126
+ coronal_ys.each_with_index do |y, i|
127
+ slice = volume[true, y, true]
128
+ miny_contents, maxy_contents = update_min_max_contents(
129
+ y, slice.max, maxv, miny_contents, maxy_contents
130
+ )
131
+ next unless (miny_contents..maxy_contents).include?(y)
132
+ output_image = output_file_name(extract_dir, 'coronal_', y.to_s)
133
+ save_transferred_pixels sequence, slice_transfer, slice, output_image,
134
+ bit_depth: bits, reverse_x: reverse_x, reverse_y: !reverse_z,
135
+ cols: scaling.scaled_nx, rows: scaling.scaled_nz,
136
+ normalize: false
137
+ progress.update_subprocess axial_zs.size + sagittal_xs.size + i
138
+ end
139
+
140
+ progress.begin_subprocess 'generating_projections', 100, n_projections
141
+
142
+ projection_count = 0
143
+
144
+ unless options.no_mip
145
+ # Generate MIP projections
146
+ slice = maximum_intensity_projection(volume, Z_AXIS)
147
+ output_image = output_file_name(extract_dir, 'axial_', 'mip')
148
+ save_transferred_pixels sequence, mip_transfer, slice, output_image,
149
+ bit_depth: bits, reverse_x: reverse_x, reverse_y: reverse_y,
150
+ cols: scaling.scaled_nx, rows: scaling.scaled_ny,
151
+ normalize: true
152
+ projection_count += 1
153
+ progress.update_subprocess projection_count
154
+
155
+ slice = maximum_intensity_projection(volume, Y_AXIS)
156
+ output_image = output_file_name(extract_dir, 'coronal_', 'mip')
157
+ save_transferred_pixels sequence, mip_transfer, slice, output_image,
158
+ bit_depth: bits, reverse_x: reverse_x, reverse_y: !reverse_z,
159
+ cols: scaling.scaled_nx, rows: scaling.scaled_nz,
160
+ normalize: true
161
+ projection_count += 1
162
+ progress.update_subprocess projection_count
163
+
164
+ slice = maximum_intensity_projection(volume, X_AXIS)
165
+ output_image = output_file_name(extract_dir, 'sagittal_', 'mip')
166
+ save_transferred_pixels sequence, mip_transfer, slice, output_image,
167
+ bit_depth: bits, reverse_x: !reverse_y, reverse_y: !reverse_z,
168
+ cols: scaling.scaled_ny, rows: scaling.scaled_nz,
169
+ normalize: true
170
+ projection_count += 1
171
+ progress.update_subprocess projection_count
172
+ end
173
+
174
+ unless options.no_aap
175
+ # Generate AAP Projections
176
+ c = dicom_window_center(sequence.first)
177
+ w = dicom_window_width(sequence.first)
178
+ dx = sequence.metadata.dx
179
+ dy = sequence.metadata.dy
180
+ dz = sequence.metadata.dz
181
+ numx = maxx_contents - minx_contents + 1 if minx_contents <= maxx_contents
182
+ numy = maxy_contents - miny_contents + 1 if miny_contents <= maxy_contents
183
+ numz = maxz_contents - minz_contents + 1 if minz_contents <= maxz_contents
184
+ daap = DynamicAap.new(
185
+ volume,
186
+ center: c, width: w, dx: dx, dy: dy, dz: dz,
187
+ numx: numx, numy: numy, numz: numz
188
+ )
189
+ slice = daap.view(Z_AXIS)
190
+ output_image = output_file_name(extract_dir, 'axial_', 'aap')
191
+ save_transferred_pixels sequence, aap_transfer, slice, output_image,
192
+ bit_depth: bits, reverse_x: reverse_x, reverse_y: reverse_y,
193
+ cols: scaling.scaled_nx, rows: scaling.scaled_ny,
194
+ normalize: true
195
+ projection_count += 1
196
+ progress.update_subprocess projection_count
197
+
198
+ slice = daap.view(Y_AXIS)
199
+ output_image = output_file_name(extract_dir, 'coronal_', 'aap')
200
+ save_transferred_pixels sequence, aap_transfer, slice, output_image,
201
+ bit_depth: bits, reverse_x: reverse_x, reverse_y: !reverse_z,
202
+ cols: scaling.scaled_nx, rows: scaling.scaled_nz,
203
+ normalize: true
204
+ projection_count += 1
205
+ progress.update_subprocess projection_count
206
+
207
+ slice = daap.view(X_AXIS)
208
+ output_image = output_file_name(extract_dir, 'sagittal_', 'aap')
209
+ save_transferred_pixels sequence, aap_transfer, slice, output_image,
210
+ bit_depth: bits, reverse_x: !reverse_y, reverse_y: !reverse_z,
211
+ cols: scaling.scaled_ny, rows: scaling.scaled_nz,
212
+ normalize: true
213
+ projection_count += 1
214
+ progress.update_subprocess projection_count
215
+ end
216
+
217
+ volume = nil
218
+ sequence.metadata.merge!(
219
+ axial_first: minz_contents, axial_last: maxz_contents,
220
+ coronal_first: miny_contents, coronal_last: maxy_contents,
221
+ sagittal_first: minx_contents, sagittal_last: maxx_contents
222
+ )
223
+ options.save_settings 'projection', sequence.metadata
224
+ progress.finish
225
+ end
226
+
227
+ def power(data, factor)
228
+ NMath.exp(factor*NMath.log(data))
229
+ end
230
+
231
+ def save_transferred_pixels(sequence, transfer, pixels, output_image, options)
232
+ dicom = sequence.first
233
+ min, max = transfer.min_max(sequence)
234
+ pixels = transfer.transfer_rescaled_pixels(dicom, pixels, min, max)
235
+ save_pixels pixels, output_image, options
236
+ end
237
+
238
+ class DynamicAap
239
+
240
+ PRE_GAMMA = 8
241
+ SUM_NORMALIZATION = false
242
+ IMAGE_GAMMA = nil
243
+ IMAGE_ADJUSTMENT = true
244
+ WINDOW_BY_DEFAULT = true
245
+ NO_WINDOW = true
246
+ IMAGE_CONTRAST = nil
247
+
248
+ def initialize(data, options)
249
+ center = options[:center]
250
+ width = options[:width]
251
+ if center && width && !NO_WINDOW
252
+ # 1. Window level normalization
253
+ if options[:window_sigmod_gamma]
254
+ # 1.a using sigmod
255
+ gamma = options[:window_sigmod_gamma] || 3.0
256
+ k0 = options[:window_sigmod_k0] || 0.06
257
+ sigmoid = Sigmoid.new(center: center, width: width, gamma: gamma, k0: k0)
258
+ data = sigmoid[data]
259
+ elsif options[:k0] || WINDOW_BY_DEFAULT
260
+ # 1.b simpler linear pseudo-sigmoid
261
+ max = data.max
262
+ min = data.min
263
+ k0 = options[:k0] || 0.1
264
+ v_lo = center - width*0.5
265
+ v_hi = center + width*0.5
266
+ low_part = (data < v_lo)
267
+ high_part = (data > v_hi)
268
+ mid_part = (low_part | high_part).not
269
+
270
+ data[low_part] -= min
271
+ data[low_part] *= k0/(v_lo - min)
272
+
273
+ data[high_part] -= v_hi
274
+ data[high_part] *= k0/(max - v_hi)
275
+ data[high_part] += 1.0 - k0
276
+
277
+ data[mid_part] -= v_lo
278
+ data[mid_part] *= (1.0 - 2*k0)/width
279
+ data[mid_part] += k0
280
+ else
281
+ # 1.c clip to window (like 1.b with k0=0)
282
+ max = data.max
283
+ min = data.min
284
+ k0 = options[:k0] || 0.02
285
+ v_lo = center - width*0.5
286
+ v_hi = center + width*0.5
287
+
288
+ low_part = (data < v_lo)
289
+ high_part = (data > v_hi)
290
+ mid_part = (low_part | high_part).not
291
+
292
+ data[low_part] = 0
293
+ data[high_part] = 1
294
+
295
+ data[mid_part] -= v_lo
296
+ data[mid_part] *= 1.0/width
297
+ end
298
+ else
299
+ # Normalize to 0-1
300
+ data.add! -data.min
301
+ data.mul! 1.0/data.max
302
+ end
303
+
304
+ if PRE_GAMMA
305
+ if [2, 4, 8].include?(PRE_GAMMA)
306
+ g = PRE_GAMMA
307
+ while g > 1
308
+ data.mul! data
309
+ g /= 2
310
+ end
311
+ else
312
+ data = power(data, PRE_GAMMA)
313
+ end
314
+ end
315
+
316
+ @data = data
317
+ @ref_num = 512
318
+ @dx = options[:dx]
319
+ @dy = options[:dy]
320
+ @dz = options[:dz]
321
+ @numx = options[:numx] || @ref_num
322
+ @numy = options[:numy] || @ref_num
323
+ @numz = options[:numz] || @ref_num
324
+ @max_output_level = options[:max] || 1.0
325
+ end
326
+
327
+ def view(axis)
328
+ case axis
329
+ when Z_AXIS
330
+ d = @dz
331
+ num = @numz
332
+ when Y_AXIS
333
+ d = @dy
334
+ num = @numy
335
+ when X_AXIS
336
+ d = @dx
337
+ num = @numx
338
+ end
339
+
340
+ s = @data.sum(axis)
341
+ s.div! s.max if SUM_NORMALIZATION
342
+ s.mul! -d*@ref_num/num
343
+ s = NMath.exp(s)
344
+
345
+ if IMAGE_GAMMA
346
+ s.mul! -1
347
+ s.add! 1
348
+ s = power(s, IMAGE_GAMMA)
349
+ contrast! s, IMAGE_CONTRAST if IMAGE_CONTRAST
350
+ s.mul! @max_output_level if @max_output_level != 1
351
+ elsif IMAGE_ADJUSTMENT
352
+ s = adjust(s)
353
+ contrast! s, IMAGE_CONTRAST if IMAGE_CONTRAST
354
+ s.mul! @max_output_level if @max_output_level != 1
355
+ else
356
+ # since contrast is vertically symmetrical we can apply it
357
+ # to the negative image
358
+ contrast! s, IMAGE_CONTRAST if IMAGE_CONTRAST
359
+ s.mul! -@max_output_level
360
+ s.add! @max_output_level
361
+ end
362
+ s
363
+ end
364
+
365
+ private
366
+
367
+ def contrast!(data, factor)
368
+ # We use this piecewise sigmoid function:
369
+ #
370
+ # * f(x) for x <= 1/2
371
+ # * 1 - f(1-x) for x > 1/2
372
+ #
373
+ # With f(x) = pow(2, factor-1)*pow(x, factor)
374
+ # The pow() function will be computed as:
375
+ # pow(x, y) = exp(y*log(x))
376
+ #
377
+ # TODO: consider this alternative:
378
+ # f(x) = (factor*x - x)/(2*factor*x - factor - 1
379
+ #)
380
+ factor = factor.round
381
+ k = 2**(factor-1)
382
+ lo = data <= 0.5
383
+ hi = data > 0.5
384
+ data[lo] = NMath.exp(factor*NMath.log(data[lo]))*k
385
+ data[hi] = (-NMath.exp(NMath.log((-data[hi]) + 1)*factor))*k+1
386
+ data
387
+ end
388
+
389
+ def power(data, factor)
390
+ NMath.exp(factor*NMath.log(data))
391
+ end
392
+
393
+ def adjust(pixels)
394
+ min = pixels.min
395
+ max = pixels.max
396
+ avg = pixels.mean
397
+ # pixels.sbt! min
398
+ # pixels.mul! 1.0/(max - min)
399
+ pixels.sbt! max
400
+ pixels.div! min - max
401
+
402
+ # HUM: target 0.7; frac: target 0.2
403
+ discriminator = (pixels > 0.83).count_true.to_f / (pixels > 0.5).count_true.to_f
404
+
405
+ avg_target = 1.0 - discriminator**0.9
406
+ x0 = 0.67
407
+ y0 = 0.72
408
+ gamma = 3.0
409
+ k = - x0**gamma/Math.log(1-y0)
410
+ avg_target = 1.0 - Math.exp(-avg_target**gamma/k)
411
+
412
+ if avg_target > 0
413
+ g = Math.log(avg_target)/Math.log(avg)
414
+ power(pixels, g)
415
+ else
416
+ pixels
417
+ end
418
+ end
419
+ end
420
+
421
+ end