dicoms 1.0.0 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
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