dicoms 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +15 -0
- data/.ruby-version +1 -0
- data/.travis.yml +4 -0
- data/CODE_OF_CONDUCT.md +13 -0
- data/Gemfile +4 -0
- data/LICENSE.md +598 -0
- data/README.md +48 -0
- data/Rakefile +22 -0
- data/bin/console +14 -0
- data/bin/setup +7 -0
- data/dicoms.gemspec +33 -0
- data/exe/dicoms +8 -0
- data/lib/dicoms.rb +67 -0
- data/lib/dicoms/cli.rb +241 -0
- data/lib/dicoms/command_options.rb +40 -0
- data/lib/dicoms/extract.rb +87 -0
- data/lib/dicoms/meta_codec.rb +131 -0
- data/lib/dicoms/pack.rb +82 -0
- data/lib/dicoms/progress.rb +80 -0
- data/lib/dicoms/projection.rb +422 -0
- data/lib/dicoms/remap.rb +46 -0
- data/lib/dicoms/sequence.rb +415 -0
- data/lib/dicoms/shared_files.rb +61 -0
- data/lib/dicoms/shared_settings.rb +111 -0
- data/lib/dicoms/stats.rb +30 -0
- data/lib/dicoms/support.rb +349 -0
- data/lib/dicoms/transfer.rb +339 -0
- data/lib/dicoms/unpack.rb +209 -0
- data/lib/dicoms/version.rb +3 -0
- metadata +200 -0
@@ -0,0 +1,40 @@
|
|
1
|
+
class DicomS
|
2
|
+
class CommandOptions < Settings
|
3
|
+
def initialize(options)
|
4
|
+
@base_dir = nil
|
5
|
+
if settings_file = options.delete(:settings_io)
|
6
|
+
@settings_io = SharedSettings.new(settings_file)
|
7
|
+
else
|
8
|
+
settings_file = options.delete(:settings)
|
9
|
+
end
|
10
|
+
if settings_file
|
11
|
+
settings = SharedSettings.new(settings_file).read
|
12
|
+
options = settings.merge(options.to_h.reject{ |k, v| v.nil? })
|
13
|
+
@base_dir = File.dirname(settings_file)
|
14
|
+
else
|
15
|
+
@base_dir = nil
|
16
|
+
end
|
17
|
+
super options
|
18
|
+
end
|
19
|
+
|
20
|
+
def path_option(option, default = nil)
|
21
|
+
path = self[option.to_sym] || default
|
22
|
+
path = File.expand_path(path, @base_dir) if @base_dir && path
|
23
|
+
path
|
24
|
+
end
|
25
|
+
|
26
|
+
attr_reader :base_name
|
27
|
+
|
28
|
+
def self.[](options)
|
29
|
+
options.is_a?(CommandOptions) ? options : CommandOptions.new(options)
|
30
|
+
end
|
31
|
+
|
32
|
+
def save_settings(command, data)
|
33
|
+
if @settings_io
|
34
|
+
@settings_io.update do |settings|
|
35
|
+
settings.merge command.to_sym => data
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
class DicomS
|
2
|
+
# extract the images of a set of DICOM files
|
3
|
+
def extract(dicom_directory, options = {})
|
4
|
+
options = CommandOptions[options]
|
5
|
+
|
6
|
+
progress = Progress.new('extracting', options)
|
7
|
+
progress.begin_subprocess 'reading_metadata', 2
|
8
|
+
|
9
|
+
strategy = define_transfer(options, :window)
|
10
|
+
sequence = Sequence.new(
|
11
|
+
dicom_directory,
|
12
|
+
transfer: strategy,
|
13
|
+
reorder: options[:reorder]
|
14
|
+
)
|
15
|
+
|
16
|
+
progress.begin_subprocess 'extracting_images', 100, sequence.size
|
17
|
+
|
18
|
+
extract_dir = options.path_option(
|
19
|
+
:output, File.join(File.expand_path(dicom_directory), 'images')
|
20
|
+
)
|
21
|
+
FileUtils.mkdir_p FileUtils.mkdir_p extract_dir
|
22
|
+
prefix = nil
|
23
|
+
min, max = sequence.metadata.min, sequence.metadata.max
|
24
|
+
sequence.each do |d, i, file|
|
25
|
+
unless prefix
|
26
|
+
prefix, name_pattern, start_number = dicom_name_pattern(file, extract_dir)
|
27
|
+
end
|
28
|
+
if options.raw
|
29
|
+
output_file = output_file_name(extract_dir, prefix, file, '.raw')
|
30
|
+
endianness = dicom_endianness(d, options)
|
31
|
+
sequence.metadata.endianness = endianness.to_s
|
32
|
+
bits = dicom_bit_depth(d)
|
33
|
+
signed = dicom_signed?(d)
|
34
|
+
fmt = pack_format(bits, signed, endianness)
|
35
|
+
File.open(output_file, 'wb') do |out|
|
36
|
+
out.write sequence.dicom_pixels(d).flatten.to_a.pack("#{fmt}*")
|
37
|
+
end
|
38
|
+
else
|
39
|
+
output_image = output_file_name(extract_dir, prefix, file)
|
40
|
+
sequence.save_jpg d, output_image
|
41
|
+
end
|
42
|
+
progress.update_subprocess i
|
43
|
+
end
|
44
|
+
|
45
|
+
metadata = cast_metadata(sequence.metadata)
|
46
|
+
metadata_yaml = File.join(extract_dir, 'metadata.yml')
|
47
|
+
File.open(metadata_yaml, 'w') do |yaml|
|
48
|
+
yaml.write metadata.to_yaml
|
49
|
+
end
|
50
|
+
|
51
|
+
progress.finish
|
52
|
+
end
|
53
|
+
|
54
|
+
def dicom_endianness(dicom, options = {})
|
55
|
+
if options[:big_endian]
|
56
|
+
:big
|
57
|
+
elsif options[:little_endian]
|
58
|
+
:little
|
59
|
+
elsif dicom.stream.str_endian
|
60
|
+
:big
|
61
|
+
else
|
62
|
+
:little
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def pack_format(bits, signed, endianness)
|
67
|
+
if bits > 16
|
68
|
+
if signed
|
69
|
+
endianness == :little ? 'l<' : 'l>'
|
70
|
+
else
|
71
|
+
endianness == :little ? 'L<' : 'L>'
|
72
|
+
end
|
73
|
+
elsif bits > 8
|
74
|
+
if signed
|
75
|
+
endianness == :little ? 's<' : 's>'
|
76
|
+
else
|
77
|
+
endianness == :little ? 'S<' : 'S>'
|
78
|
+
end
|
79
|
+
else
|
80
|
+
if signed
|
81
|
+
"c"
|
82
|
+
else
|
83
|
+
"C"
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,131 @@
|
|
1
|
+
# Inserting/extracting DICOM metadata in video files with FFMpeg
|
2
|
+
#
|
3
|
+
# Example of use:
|
4
|
+
#
|
5
|
+
# # Add metadata to a file
|
6
|
+
# dicom = DICOM::DObject.read(dicom_file)
|
7
|
+
# meta_codec = MetaCodec.new(mode: :chunked)
|
8
|
+
# meta_file = 'ffmetadata'
|
9
|
+
# meta_codec.write_metadata(dicom, meta_file, dx: 111, dy: 222, dz: 333)
|
10
|
+
# input_file = 'video.mkv'
|
11
|
+
# output_file = 'video_with_metadata.mkv'
|
12
|
+
# `ffmpeg -i #{input_file} -i #{meta_file} -map_metadata 1 -codec copy #{output_file}`
|
13
|
+
#
|
14
|
+
# # Extract metadata from a file
|
15
|
+
# `ffmpeg -i #{output_file} -f ffmetadata #{meta_file}`
|
16
|
+
# dicom_elements, additional_values = meta_codec.read_metadata(meta_file)
|
17
|
+
#
|
18
|
+
class DicomS::MetaCodec
|
19
|
+
|
20
|
+
# Two encoding modes:
|
21
|
+
# * :chunked : use few metadata entries (dicom_0)
|
22
|
+
# that encode all the DICOM elements (several are used because there's a limit
|
23
|
+
# in the length of a single metadata entry)
|
24
|
+
# * :individual : use individual metadata entries for each DICOM tag
|
25
|
+
def initialize(options = {})
|
26
|
+
@mode = options[:mode] || :individual
|
27
|
+
end
|
28
|
+
|
29
|
+
def encode_metadata(dicom, additional_metadata = {}, &blk)
|
30
|
+
elements = dicom.elements.select{|e| !e.value.nil?}
|
31
|
+
elements = elements.select(&blk) if blk
|
32
|
+
elements = elements.map{|e| [e.tag, e.value]}
|
33
|
+
case @mode
|
34
|
+
when :chunked
|
35
|
+
txt = elements.map{|tag, value| "#{inner_escape(tag)}#{VALUE_SEPARATOR}#{inner_escape(value)}"}.join(PAIR_SEPARATOR)
|
36
|
+
chunks = in_chunks(txt, CHUNK_SIZE).map{|txt| escape(txt)}
|
37
|
+
metadata = Hash[chunks.each_with_index.to_a.map(&:reverse)]
|
38
|
+
else
|
39
|
+
pairs = elements.map { |tag, value|
|
40
|
+
group, tag = tag.split(',')
|
41
|
+
["#{group}_#{tag}", escape(value)]
|
42
|
+
}
|
43
|
+
metadata = Hash[pairs]
|
44
|
+
end
|
45
|
+
|
46
|
+
metadata.merge(additional_metadata)
|
47
|
+
end
|
48
|
+
|
49
|
+
# Write DICOM metatada encoded for FFMpeg into a metadatafile
|
50
|
+
# The file can be attached to a video input_file with:
|
51
|
+
# `ffmpeg -i #{input_file} -i #{metadatafile} -map_metadata 1 -codec copy #{output_file}`
|
52
|
+
def write_metadata(dicom, metadatafile, additional_metadata = {}, &blk)
|
53
|
+
metadata = encode_metadata(dicom, additional_metadata, &blk)
|
54
|
+
File.open(metadatafile, 'w') do |file|
|
55
|
+
file.puts ";FFMETADATA1"
|
56
|
+
metadata.each do |name, value|
|
57
|
+
file.puts "dicom_#{name}=#{value}"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def decode_metadata(txt)
|
63
|
+
txt = unescape(txt)
|
64
|
+
data = txt.split(PAIR_SEPARATOR).map{|pair| pair.split(VALUE_SEPARATOR)}
|
65
|
+
data = data.map{|tag, value| [inner_unescape(tag), inner_unescape(value)]}
|
66
|
+
data.map{|tag, value|
|
67
|
+
DICOM::Element.new(tag, value)
|
68
|
+
}
|
69
|
+
end
|
70
|
+
|
71
|
+
# Can extract the metadatafile from a video input_file with:
|
72
|
+
# `ffmpeg -i #{input_file} -f ffmetadata #{metadatafile}`
|
73
|
+
def read_metadata(metadatafile)
|
74
|
+
lines = File.read(metadatafile).lines[1..-1]
|
75
|
+
lines = lines.reject { |line|
|
76
|
+
line = line.strip
|
77
|
+
line.empty? || line[0, 1] == '#' || line[0, 1] == ';'
|
78
|
+
}
|
79
|
+
chunks = []
|
80
|
+
elements = []
|
81
|
+
additional_metadata = {}
|
82
|
+
lines.each do |line|
|
83
|
+
key, value = line.strip.split('=')
|
84
|
+
key = key.downcase
|
85
|
+
if match = key.match(/\Adicom_(\d+)\Z/)
|
86
|
+
i = match[1].to_i
|
87
|
+
chunks << [i, value]
|
88
|
+
elsif match = key.match(/\Adicom_(\h+)_(\h+)\Z/)
|
89
|
+
group = match[1]
|
90
|
+
tag = match[2]
|
91
|
+
tag = "#{group},#{tag}"
|
92
|
+
elements << DICOM::Element.new(tag, unescape(value))
|
93
|
+
elsif match = key.match(/\Adicom_(.+)\Z/)
|
94
|
+
additional_metadata[match[1].downcase.to_sym] = value # TODO: type conversion?
|
95
|
+
end
|
96
|
+
end
|
97
|
+
if chunks.size > 0
|
98
|
+
elements += decode_metadata(chunks.sort_by(&:first).map(&:last).join)
|
99
|
+
end
|
100
|
+
[elements, additional_metadata]
|
101
|
+
end
|
102
|
+
|
103
|
+
private
|
104
|
+
|
105
|
+
def escape(txt)
|
106
|
+
txt.to_s.gsub('\\', Regexp.quote('\\\\')).gsub('=', '\\=').gsub(';', '\\;').gsub('#', '\\#').gsub('\n', '\\\n')
|
107
|
+
end
|
108
|
+
|
109
|
+
def unescape(txt)
|
110
|
+
txt.to_s.gsub('\\\\', Regexp.quote('\\')).gsub('\\=', '=').gsub('\\;', ';').gsub('\\#', '#').gsub('\\\n', '\n')
|
111
|
+
end
|
112
|
+
|
113
|
+
VALUE_SEPARATOR = '>'
|
114
|
+
PAIR_SEPARATOR = '<'
|
115
|
+
|
116
|
+
def inner_escape(txt)
|
117
|
+
txt.to_s.gsub(VALUE_SEPARATOR, '[[METACODEC_VSEP]]').gsub(PAIR_SEPARATOR, '[[METACODEC_PSEP]]')
|
118
|
+
end
|
119
|
+
|
120
|
+
def inner_unescape(txt)
|
121
|
+
txt.to_s.gsub('[[METACODEC_VSEP]]', VALUE_SEPARATOR).gsub('[[METACODEC_PSEP]]', PAIR_SEPARATOR)
|
122
|
+
end
|
123
|
+
|
124
|
+
CHUNK_SIZE = 800
|
125
|
+
|
126
|
+
def in_chunks(txt, max_size=CHUNK_SIZE)
|
127
|
+
# txt.chars.each_slice(max_size).map(&:join)
|
128
|
+
txt.scan(/.{1,#{max_size}}/)
|
129
|
+
end
|
130
|
+
|
131
|
+
end
|
data/lib/dicoms/pack.rb
ADDED
@@ -0,0 +1,82 @@
|
|
1
|
+
class DicomS
|
2
|
+
def pack(dicom_directory, options = {})
|
3
|
+
# TODO: keep more metadata to restore the exact strategy+min,max and so
|
4
|
+
# be able to restore original DICOM values (and rescaling/window metadata)
|
5
|
+
# bit depth, signed/unsigned, rescale, window, data values corresponding
|
6
|
+
# to minimum (black) and maximum (white)
|
7
|
+
options = CommandOptions[options]
|
8
|
+
|
9
|
+
progress = Progress.new('packaging', options)
|
10
|
+
progress.begin_subprocess 'reading_metadata', 2
|
11
|
+
|
12
|
+
strategy = define_transfer(options, :sample)
|
13
|
+
sequence = Sequence.new(
|
14
|
+
dicom_directory,
|
15
|
+
transfer: strategy,
|
16
|
+
roi: options[:roi],
|
17
|
+
reorder: options[:reorder]
|
18
|
+
)
|
19
|
+
|
20
|
+
output_name = (options.path_option(:output) || File.basename(dicom_directory)) + '.mkv'
|
21
|
+
pack_dir = options.path_option(:tmp, 'dspack_tmp') # TODO: better default
|
22
|
+
FileUtils.mkdir_p pack_dir
|
23
|
+
|
24
|
+
name_pattern = start_number = prefix = nil
|
25
|
+
|
26
|
+
progress.begin_subprocess 'extracting_images', 60, sequence.size
|
27
|
+
image_files = []
|
28
|
+
keeping_path do
|
29
|
+
sequence.each do |d, i, file|
|
30
|
+
unless name_pattern
|
31
|
+
prefix, name_pattern, start_number = dicom_name_pattern(file, pack_dir)
|
32
|
+
end
|
33
|
+
output_image = output_file_name(pack_dir, prefix, file)
|
34
|
+
image_files << output_image
|
35
|
+
sequence.save_jpg d, output_image
|
36
|
+
progress.update_subprocess i
|
37
|
+
end
|
38
|
+
end
|
39
|
+
if options[:dicom_metadata]
|
40
|
+
metadata_file = File.join(pack_dir, 'ffmetadata')
|
41
|
+
meta_codec.write_metadata(sequence.first, metadata_file, sequence.metadata.to_h)
|
42
|
+
end
|
43
|
+
progress.begin_subprocess 'packing_images'
|
44
|
+
ffmpeg = SysCmd.command('ffmpeg', @ffmpeg_options) do
|
45
|
+
# global options
|
46
|
+
option '-y' # overwrite existing files
|
47
|
+
option '-loglevel', 'quiet'
|
48
|
+
option '-hide_banner'
|
49
|
+
|
50
|
+
# input
|
51
|
+
option '-start_number', start_number
|
52
|
+
option '-i', name_pattern
|
53
|
+
|
54
|
+
# metadata
|
55
|
+
if metadata_file
|
56
|
+
# additional input: metadata
|
57
|
+
option '-i', metadata_file
|
58
|
+
# use metadata from input file #1 (0 is the image sequence)
|
59
|
+
option '-map_metadata', 1
|
60
|
+
else
|
61
|
+
sequence.metadata.each do |key, value|
|
62
|
+
option '-metadata', "dicom_#{key}", equal_value: value
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# output
|
67
|
+
option '-vcodec', 'mjpeg'
|
68
|
+
option '-q:v', '2.0'
|
69
|
+
file output_name
|
70
|
+
end
|
71
|
+
ffmpeg.run error_output: :separate
|
72
|
+
check_command ffmpeg
|
73
|
+
if File.expand_path(File.dirname(output_name)) == File.expand_path(pack_dir)
|
74
|
+
image_files.files.each do |file|
|
75
|
+
FileUtils.rm file
|
76
|
+
end
|
77
|
+
else
|
78
|
+
FileUtils.rm_rf pack_dir
|
79
|
+
end
|
80
|
+
progress.finish
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
class DicomS
|
2
|
+
class Progress
|
3
|
+
def initialize(description, options={})
|
4
|
+
options = CommandOptions[options]
|
5
|
+
filename = options.path_option(:progress)
|
6
|
+
@progress = options[:initial_progress] || 0
|
7
|
+
# TODO: if filename == :console, show progress on the console
|
8
|
+
if filename
|
9
|
+
@file = SharedSettings.new(
|
10
|
+
filename,
|
11
|
+
replace_contents: {
|
12
|
+
process: description,
|
13
|
+
subprocess: options[:subprocess],
|
14
|
+
progress: @progress
|
15
|
+
}
|
16
|
+
)
|
17
|
+
else
|
18
|
+
@file = nil
|
19
|
+
end
|
20
|
+
@subprocess_start = @subprocess_size = @subprocess_percent = nil
|
21
|
+
end
|
22
|
+
|
23
|
+
attr_reader :progress
|
24
|
+
|
25
|
+
def finished?
|
26
|
+
@progress >= 100
|
27
|
+
end
|
28
|
+
|
29
|
+
def update(value, subprocess = nil)
|
30
|
+
@progress = value
|
31
|
+
if @file
|
32
|
+
@file.update do |data|
|
33
|
+
data.progress = @progress
|
34
|
+
data.subprocess = subprocess if subprocess
|
35
|
+
data
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def finish
|
41
|
+
update 100, 'finished'
|
42
|
+
end
|
43
|
+
|
44
|
+
# Begin a subprocess which represent `percent`
|
45
|
+
# of the total process. The subprocess will be measured
|
46
|
+
# with values up to `size`
|
47
|
+
def begin_subprocess(description, percent=nil, size=0)
|
48
|
+
end_subprocess if @subprocess_start
|
49
|
+
@subprocess_start = @progress
|
50
|
+
percent ||= 100
|
51
|
+
if percent < 0
|
52
|
+
# interpreted as percent of what's lef
|
53
|
+
percent = (100 - @progress)*(-percent)/100.0
|
54
|
+
end
|
55
|
+
percent = [percent, 100 - @progress].min
|
56
|
+
# @subprocess_end = @progress + percent
|
57
|
+
@subprocess_size = size.to_f
|
58
|
+
@subprocess_percent = percent
|
59
|
+
update @progress, description
|
60
|
+
end
|
61
|
+
|
62
|
+
def update_subprocess(value)
|
63
|
+
raise "Subprocess not started" unless @subprocess_start
|
64
|
+
sub_fraction = value/@subprocess_size
|
65
|
+
@progress = @subprocess_start + @subprocess_percent*sub_fraction
|
66
|
+
if @subprocess_size < 20 || (value % 10) == 0
|
67
|
+
# frequently updated processes don't update the file every
|
68
|
+
# fime to avoid the overhead (just 1 in 10 times)
|
69
|
+
update @progress
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def end_subprocess
|
74
|
+
raise "Subprocess not started" unless @subprocess_start
|
75
|
+
@progress = @subprocess_start + @subprocess_percent
|
76
|
+
@subprocess_start = @subprocess_size = @subprocess_percent = nil
|
77
|
+
update @progress
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,422 @@
|
|
1
|
+
require 'rmagick'
|
2
|
+
|
3
|
+
class DicomS
|
4
|
+
NORMALIZE_PROJECTION_IMAGES = true
|
5
|
+
ASSIGN_IMAGE_PIXELS_AS_ARRAY = true
|
6
|
+
ADJUST_AAP_FOR_WIDTH = true
|
7
|
+
|
8
|
+
# extract projected images of a set of DICOM files
|
9
|
+
def projection(dicom_directory, options = {})
|
10
|
+
options = CommandOptions[options]
|
11
|
+
|
12
|
+
progress = Progress.new('projecting', options)
|
13
|
+
progress.begin_subprocess 'reading_metadata', 1
|
14
|
+
|
15
|
+
# We can save on memory use by using 8-bit processing, so it will be the default
|
16
|
+
strategy = define_transfer(options, :window, output: :byte)
|
17
|
+
sequence = Sequence.new(
|
18
|
+
dicom_directory,
|
19
|
+
transfer: strategy,
|
20
|
+
reorder: options[:reorder]
|
21
|
+
)
|
22
|
+
|
23
|
+
extract_dir = options.path_option(
|
24
|
+
:output, File.join(File.expand_path(dicom_directory), 'images')
|
25
|
+
)
|
26
|
+
FileUtils.mkdir_p extract_dir
|
27
|
+
|
28
|
+
if sequence.metadata.lim_max <= 255
|
29
|
+
bits = 8
|
30
|
+
else
|
31
|
+
bits = 16
|
32
|
+
end
|
33
|
+
|
34
|
+
scaling = projection_scaling(sequence.metadata, options)
|
35
|
+
sequence.metadata.merge! scaling
|
36
|
+
scaling = Settings[scaling]
|
37
|
+
|
38
|
+
reverse_x = sequence.metadata.reverse_x.to_i == 1
|
39
|
+
reverse_y = sequence.metadata.reverse_y.to_i == 1
|
40
|
+
reverse_z = sequence.metadata.reverse_z.to_i == 1
|
41
|
+
|
42
|
+
maxx = sequence.metadata.nx
|
43
|
+
maxy = sequence.metadata.ny
|
44
|
+
maxz = sequence.metadata.nz
|
45
|
+
|
46
|
+
# minimum and maximum slices with non-(almost)-blank contents
|
47
|
+
minx_contents = maxx
|
48
|
+
maxx_contents = 0
|
49
|
+
miny_contents = maxy
|
50
|
+
maxy_contents = 0
|
51
|
+
minz_contents = maxz
|
52
|
+
maxz_contents = 0
|
53
|
+
|
54
|
+
if full_projection?(options[:axial]) || full_projection?(options[:coronal]) || full_projection?(options[:sagittal])
|
55
|
+
percent = 65
|
56
|
+
else
|
57
|
+
percent = 90
|
58
|
+
end
|
59
|
+
progress.begin_subprocess 'generating_volume', percent, maxz
|
60
|
+
|
61
|
+
# Load all the slices into a (big) 3D array
|
62
|
+
if bits == 8
|
63
|
+
# TODO: support signed too
|
64
|
+
volume = NArray.byte(maxx, maxy, maxz)
|
65
|
+
else
|
66
|
+
# With type NArray::SINT instead of NArray::INT we would use up half the
|
67
|
+
# memory, but each slice to be converted to image would have to be
|
68
|
+
# convertd to INT...
|
69
|
+
# volume = NArray.sint(maxx, maxy, maxz)
|
70
|
+
volume = NArray.int(maxx, maxy, maxz)
|
71
|
+
end
|
72
|
+
keeping_path do
|
73
|
+
sequence.each do |dicom, z, file|
|
74
|
+
slice = sequence.dicom_pixels(dicom, unsigned: true)
|
75
|
+
volume[true, true, z] = slice
|
76
|
+
if center_slice_projection?(options[:axial])
|
77
|
+
minz_contents, maxz_contents = update_min_max_contents(
|
78
|
+
z, slice.max, maxy, minz_contents, maxz_contents
|
79
|
+
)
|
80
|
+
end
|
81
|
+
progress.update_subprocess z
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
if center_slice_projection?(options[:coronal])
|
86
|
+
(0...maxy).each do |y|
|
87
|
+
miny_contents, maxy_contents = update_min_max_contents(
|
88
|
+
y, volume[true, y, true].max, maxy, miny_contents, maxy_contents
|
89
|
+
)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
if center_slice_projection?(options[:sagittal])
|
94
|
+
(0...maxz).each do |z|
|
95
|
+
minz_contents, maxz_contents = update_min_max_contents(
|
96
|
+
z, volume[true, true, z].max, maxz, minz_contents, maxz_contents
|
97
|
+
)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
if single_slice_projection?(options[:axial])
|
102
|
+
axial_zs = [options[:axial].to_i]
|
103
|
+
elsif center_slice_projection?(options[:axial])
|
104
|
+
axial_zs = [[(minz_contents+maxz_contents)/2, 'c']]
|
105
|
+
elsif middle_slice_projection?(options[:axial])
|
106
|
+
axial_zs = [[maxz/2, 'm']]
|
107
|
+
elsif full_projection?(options[:axial])
|
108
|
+
axial_zs = (0...maxz)
|
109
|
+
else
|
110
|
+
axial_zs = []
|
111
|
+
end
|
112
|
+
|
113
|
+
if single_slice_projection?(options[:sagittal])
|
114
|
+
sagittal_xs = [options[:sagittal].to_i]
|
115
|
+
elsif center_slice_projection?(options[:sagittal])
|
116
|
+
sagittal_xs = [[(minx_contents+maxx_contents)/2, 'c']]
|
117
|
+
elsif middle_slice_projection?(options[:sagittal])
|
118
|
+
sagittal_xs = [[maxx/2, 'm']]
|
119
|
+
elsif full_projection?(options[:sagittal])
|
120
|
+
sagittal_xs = (0...maxx)
|
121
|
+
else
|
122
|
+
sagittal_xs = []
|
123
|
+
end
|
124
|
+
|
125
|
+
if single_slice_projection?(options[:coronal])
|
126
|
+
coronal_ys = [options[:coronal].to_i]
|
127
|
+
elsif center_slice_projection?(options[:coronal])
|
128
|
+
coronal_ys = [[(miny_contents+maxy_contents)/2, 'c']]
|
129
|
+
elsif middle_slice_projection?(options[:coronal])
|
130
|
+
coronal_ys = [[maxy/2, 'm']]
|
131
|
+
elsif full_projection?(options[:coronal])
|
132
|
+
coronal_ys = (0...maxx)
|
133
|
+
else
|
134
|
+
coronal_ys = []
|
135
|
+
end
|
136
|
+
|
137
|
+
n = axial_zs.size + sagittal_xs.size + coronal_ys.size
|
138
|
+
|
139
|
+
progress.begin_subprocess 'generating_slices', -70, n if n > 0
|
140
|
+
axial_zs.each_with_index do |(z, suffix), i|
|
141
|
+
slice = volume[true, true, z]
|
142
|
+
output_image = output_file_name(extract_dir, 'axial_', suffix || z.to_s)
|
143
|
+
save_pixels slice, output_image,
|
144
|
+
bit_depth: bits, reverse_x: reverse_x, reverse_y: reverse_y,
|
145
|
+
cols: scaling.scaled_nx, rows: scaling.scaled_ny,
|
146
|
+
normalize: NORMALIZE_PROJECTION_IMAGES
|
147
|
+
progress.update_subprocess i
|
148
|
+
end
|
149
|
+
sagittal_xs.each_with_index do |(x, suffix), i|
|
150
|
+
slice = volume[x, true, true]
|
151
|
+
output_image = output_file_name(extract_dir, 'sagittal_', suffix || x.to_s)
|
152
|
+
save_pixels slice, output_image,
|
153
|
+
bit_depth: bits, reverse_x: !reverse_y, reverse_y: !reverse_z,
|
154
|
+
cols: scaling.scaled_ny, rows: scaling.scaled_nz,
|
155
|
+
normalize: NORMALIZE_PROJECTION_IMAGES
|
156
|
+
progress.update_subprocess axial_zs.size + i
|
157
|
+
end
|
158
|
+
coronal_ys.each_with_index do |(y, suffix), i|
|
159
|
+
slice = volume[true, y, true]
|
160
|
+
output_image = output_file_name(extract_dir, 'coronal_', suffix || y.to_s)
|
161
|
+
save_pixels slice, output_image,
|
162
|
+
bit_depth: bits, reverse_x: reverse_x, reverse_y: !reverse_z,
|
163
|
+
cols: scaling.scaled_nx, rows: scaling.scaled_nz,
|
164
|
+
normalize: NORMALIZE_PROJECTION_IMAGES
|
165
|
+
progress.update_subprocess axial_zs.size + sagittal_xs.size + i
|
166
|
+
end
|
167
|
+
|
168
|
+
n = [:axial, :coronal, :sagittal].map{ |axis|
|
169
|
+
aggregate_projection?(options[axis]) ? 1 : 0
|
170
|
+
}.inject(&:+)
|
171
|
+
progress.begin_subprocess 'generating_projections', 100, n if n > 0
|
172
|
+
i = 0
|
173
|
+
|
174
|
+
float_v = nil
|
175
|
+
if options.to_h.values_at(:axial, :coronal, :sagittal).any?{ |sel|
|
176
|
+
aggregate_projection_includes?(sel, 'aap')
|
177
|
+
}
|
178
|
+
# It's gonna take memory... (a whole lot of precious memory)
|
179
|
+
float_v ||= volume.to_type(NArray::SFLOAT)
|
180
|
+
# To enhance result contrast we will apply a gamma of x**4
|
181
|
+
float_v.mul! 1.0/float_v.max
|
182
|
+
float_v.mul! float_v
|
183
|
+
float_v.mul! float_v
|
184
|
+
end
|
185
|
+
if aggregate_projection?(options[:axial])
|
186
|
+
views = []
|
187
|
+
if aggregate_projection_includes?(options[:axial], 'aap')
|
188
|
+
slice = accumulated_attenuation_projection(
|
189
|
+
float_v, Z_AXIS, sequence.metadata.lim_max, maxz
|
190
|
+
).to_type(volume.typecode)
|
191
|
+
views << ['aap', slice]
|
192
|
+
end
|
193
|
+
if aggregate_projection_includes?(options[:axial], 'mip')
|
194
|
+
slice = maximum_intensity_projection(volume, Z_AXIS)
|
195
|
+
views << ['mip', slice]
|
196
|
+
end
|
197
|
+
views.each do |view, slice|
|
198
|
+
output_image = output_file_name(extract_dir, 'axial_', view)
|
199
|
+
save_pixels slice, output_image,
|
200
|
+
bit_depth: bits, reverse_x: reverse_x, reverse_y: reverse_y,
|
201
|
+
cols: scaling.scaled_nx, rows: scaling.scaled_ny,
|
202
|
+
normalize: NORMALIZE_PROJECTION_IMAGES
|
203
|
+
end
|
204
|
+
i += 1
|
205
|
+
progress.update_subprocess i
|
206
|
+
end
|
207
|
+
if aggregate_projection?(options[:coronal])
|
208
|
+
views = []
|
209
|
+
if aggregate_projection_includes?(options[:coronal], 'aap')
|
210
|
+
# It's gonna take memory... (a whole lot of precious memory)
|
211
|
+
float_v ||= volume.to_type(NArray::SFLOAT)
|
212
|
+
slice = accumulated_attenuation_projection(
|
213
|
+
float_v, Y_AXIS, sequence.metadata.lim_max, maxy
|
214
|
+
).to_type(volume.typecode)
|
215
|
+
views << ['aap', slice]
|
216
|
+
end
|
217
|
+
if aggregate_projection_includes?(options[:coronal], 'mip')
|
218
|
+
slice = maximum_intensity_projection(volume, Y_AXIS)
|
219
|
+
views << ['mip', slice]
|
220
|
+
end
|
221
|
+
views.each do |view, slice|
|
222
|
+
output_image = output_file_name(extract_dir, 'coronal_', view)
|
223
|
+
save_pixels slice, output_image,
|
224
|
+
bit_depth: bits, reverse_x: reverse_x, reverse_y: !reverse_z,
|
225
|
+
cols: scaling.scaled_nx, rows: scaling.scaled_nz,
|
226
|
+
normalize: NORMALIZE_PROJECTION_IMAGES
|
227
|
+
end
|
228
|
+
i += 1
|
229
|
+
progress.update_subprocess i
|
230
|
+
end
|
231
|
+
if aggregate_projection?(options[:sagittal])
|
232
|
+
views = []
|
233
|
+
if aggregate_projection_includes?(options[:sagittal], 'aap')
|
234
|
+
# It's gonna take memory... (a whole lot of precious memory)
|
235
|
+
float_v ||= volume.to_type(NArray::SFLOAT)
|
236
|
+
slice = accumulated_attenuation_projection(
|
237
|
+
float_v, X_AXIS, sequence.metadata.lim_max, maxx
|
238
|
+
).to_type(volume.typecode)
|
239
|
+
views << ['aap', slice]
|
240
|
+
end
|
241
|
+
if aggregate_projection_includes?(options[:sagittal], 'mip')
|
242
|
+
slice = maximum_intensity_projection(volume, X_AXIS)
|
243
|
+
views << ['mip', slice]
|
244
|
+
end
|
245
|
+
views.each do |view, slice|
|
246
|
+
output_image = output_file_name(extract_dir, 'sagittal_', view)
|
247
|
+
save_pixels slice, output_image,
|
248
|
+
bit_depth: bits, reverse_x: !reverse_y, reverse_y: !reverse_z,
|
249
|
+
cols: scaling.scaled_ny, rows: scaling.scaled_nz,
|
250
|
+
normalize: NORMALIZE_PROJECTION_IMAGES
|
251
|
+
end
|
252
|
+
i += 1
|
253
|
+
progress.update_subprocess i
|
254
|
+
end
|
255
|
+
float_v = nil
|
256
|
+
options.save_settings 'projection', sequence.metadata
|
257
|
+
progress.finish
|
258
|
+
end
|
259
|
+
|
260
|
+
private
|
261
|
+
|
262
|
+
X_AXIS = 0
|
263
|
+
Y_AXIS = 1
|
264
|
+
Z_AXIS = 2
|
265
|
+
|
266
|
+
def update_min_max_contents(pos, max, ref_max, current_min, current_max)
|
267
|
+
if max/ref_max.to_f >= 0.05
|
268
|
+
current_min = [current_min, pos].min
|
269
|
+
current_max = [current_max, pos].max
|
270
|
+
end
|
271
|
+
[current_min, current_max]
|
272
|
+
end
|
273
|
+
|
274
|
+
def maximum_intensity_projection(v, axis)
|
275
|
+
v.max(axis)
|
276
|
+
end
|
277
|
+
|
278
|
+
def accumulated_attenuation_projection(float_v, axis, max_output_level, max=500)
|
279
|
+
k = 0.02
|
280
|
+
if ADJUST_AAP_FOR_WIDTH
|
281
|
+
k *= 500.0/max
|
282
|
+
end
|
283
|
+
v = float_v.sum(axis)
|
284
|
+
v.mul! -k
|
285
|
+
v = NMath.exp(v)
|
286
|
+
# Invert result (from attenuation to transmission)
|
287
|
+
v.mul! -max_output_level
|
288
|
+
v.add! max_output_level
|
289
|
+
v
|
290
|
+
end
|
291
|
+
|
292
|
+
def axis_index(v, maxv, reverse)
|
293
|
+
reverse ? maxv - v : v
|
294
|
+
end
|
295
|
+
|
296
|
+
def aggregate_projection?(axis_selection)
|
297
|
+
axis_selection && axis_selection.split(',').any? { |sel|
|
298
|
+
['*', 'mip', 'aap'].include?(sel)
|
299
|
+
}
|
300
|
+
end
|
301
|
+
|
302
|
+
def full_projection?(axis_selection)
|
303
|
+
axis_selection && axis_selection.split(',').any? { |sel| sel == '*' }
|
304
|
+
end
|
305
|
+
|
306
|
+
def single_slice_projection?(axis_selection)
|
307
|
+
axis_selection.is_a?(String) && /\A\d+\Z/i =~ axis_selection
|
308
|
+
end
|
309
|
+
|
310
|
+
def center_slice_projection?(axis_selection)
|
311
|
+
axis_selection && axis_selection.split(',').any? { |sel| sel.downcase == 'c' }
|
312
|
+
end
|
313
|
+
|
314
|
+
def middle_slice_projection?(axis_selection)
|
315
|
+
axis_selection && axis_selection.split(',').any? { |sel| sel.downcase == 'm' }
|
316
|
+
end
|
317
|
+
|
318
|
+
def aggregate_projection_includes?(axis_selection, projection)
|
319
|
+
axis_selection && axis_selection.split(',').any? { |sel|
|
320
|
+
sel == projection
|
321
|
+
}
|
322
|
+
end
|
323
|
+
|
324
|
+
def projection_scaling(data, options = {})
|
325
|
+
sx = sy = sz = 1
|
326
|
+
|
327
|
+
nx = data.nx
|
328
|
+
ny = data.ny
|
329
|
+
nz = data.nz
|
330
|
+
dx = data.dx
|
331
|
+
dy = data.dy
|
332
|
+
dz = data.dz
|
333
|
+
|
334
|
+
unless dx == dy && dy == dz
|
335
|
+
# need to cope with different scales in different axes.
|
336
|
+
# will always produce shrink factors (<1)
|
337
|
+
ref = [dx, dy, dz].max.to_f
|
338
|
+
sx = dx/ref
|
339
|
+
sy = dy/ref
|
340
|
+
sz = dz/ref
|
341
|
+
end
|
342
|
+
|
343
|
+
scaled_nx = (nx*sx).round
|
344
|
+
scaled_ny = (ny*sy).round
|
345
|
+
scaled_nz = (nz*sz).round
|
346
|
+
|
347
|
+
# further shrinking may be needed to avoid any projection
|
348
|
+
# to be larger thant the maximum image size
|
349
|
+
# Axis X is the columns of axial an coronal views
|
350
|
+
# Axis Y is the rows of axial and the columns of sagittal
|
351
|
+
# Axis Z is the rows of coronal and sagittal views
|
352
|
+
max_nx = [scaled_nx, options.max_x_pixels, options[:maxcols]].compact.min
|
353
|
+
if scaled_nx > max_nx
|
354
|
+
scaled_nx = max_nx
|
355
|
+
sx = scaled_nx/nx.to_f
|
356
|
+
end
|
357
|
+
|
358
|
+
max_ny = [scaled_ny, options.max_y_pixels, options[:maxcols], options[:maxrows]].compact.min
|
359
|
+
if scaled_ny > max_ny
|
360
|
+
scaled_ny = max_ny
|
361
|
+
sy = scaled_ny/ny.to_f
|
362
|
+
end
|
363
|
+
|
364
|
+
max_nz = [scaled_nz, options.max_z_pixels, options[:maxrows]].compact.min
|
365
|
+
if scaled_nz > max_nz
|
366
|
+
scaled_nz = max_nz
|
367
|
+
sz = scaled_nz/nz.to_f
|
368
|
+
end
|
369
|
+
|
370
|
+
{
|
371
|
+
scale_x: sx, scale_y: sy, scale_z: sz,
|
372
|
+
scaled_nx: scaled_nx, scaled_ny: scaled_ny, scaled_nz: scaled_nz
|
373
|
+
}
|
374
|
+
end
|
375
|
+
|
376
|
+
def save_pixels(pixels, output_image, options = {})
|
377
|
+
bits = options[:bit_depth] || 16
|
378
|
+
reverse_x = options[:reverse_x]
|
379
|
+
reverse_y = options[:reverse_y]
|
380
|
+
normalize = options[:normalize]
|
381
|
+
|
382
|
+
# max image size
|
383
|
+
scaled_columns = options[:cols]
|
384
|
+
scaled_rows = options[:rows]
|
385
|
+
|
386
|
+
columns, rows = pixels.shape
|
387
|
+
|
388
|
+
if ASSIGN_IMAGE_PIXELS_AS_ARRAY
|
389
|
+
# assign from array
|
390
|
+
if Magick::MAGICKCORE_QUANTUM_DEPTH != bits
|
391
|
+
if bits == 8
|
392
|
+
# scale up the data
|
393
|
+
pixels = pixels.to_type(NArray::INT)
|
394
|
+
pixels.mul! 256
|
395
|
+
else
|
396
|
+
# scale down
|
397
|
+
pixels.div! 256
|
398
|
+
pixels = pixels.to_type(NArray::BYTE) # FIXME: necessary?
|
399
|
+
end
|
400
|
+
end
|
401
|
+
image = Magick::Image.new(columns, rows).import_pixels(0, 0, columns, rows, 'I', pixels.flatten)
|
402
|
+
else
|
403
|
+
# Pack to a String (blob) and let Magick do the conversion
|
404
|
+
if bits == 8
|
405
|
+
rm_type = Magick::CharPixel
|
406
|
+
blob = pixels.flatten.to_a.pack('C*')
|
407
|
+
else
|
408
|
+
rm_type = Magick::ShortPixel
|
409
|
+
blob = pixels.flatten.to_a.pack('S<*')
|
410
|
+
end
|
411
|
+
image = Magick::Image.new(columns, rows).import_pixels(0, 0, columns, rows, 'I', blob, rm_type)
|
412
|
+
end
|
413
|
+
image.flip! if reverse_y
|
414
|
+
image.flop! if reverse_x
|
415
|
+
image = image.normalize if normalize
|
416
|
+
if scaled_columns != columns || scaled_rows != rows
|
417
|
+
image = image.resize(scaled_columns, scaled_rows)
|
418
|
+
end
|
419
|
+
image.write(output_image)
|
420
|
+
end
|
421
|
+
|
422
|
+
end
|