dicom 0.9.6 → 0.9.7
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 +5 -13
- data/CHANGELOG.md +390 -376
- data/COPYING +674 -674
- data/Gemfile +2 -2
- data/Gemfile.lock +30 -28
- data/README.md +154 -152
- data/dicom.gemspec +30 -30
- data/lib/dicom/anonymizer.rb +677 -654
- data/lib/dicom/audit_trail.rb +109 -109
- data/lib/dicom/d_library.rb +269 -265
- data/lib/dicom/d_object.rb +465 -465
- data/lib/dicom/d_read.rb +21 -8
- data/lib/dicom/d_server.rb +329 -329
- data/lib/dicom/d_write.rb +355 -355
- data/lib/dicom/dictionary/elements.tsv +597 -86
- data/lib/dicom/dictionary/uids.tsv +4 -2
- data/lib/dicom/elemental_parent.rb +63 -63
- data/lib/dicom/extensions/array.rb +56 -56
- data/lib/dicom/extensions/hash.rb +30 -30
- data/lib/dicom/extensions/string.rb +125 -125
- data/lib/dicom/file_handler.rb +121 -121
- data/lib/dicom/general/constants.rb +210 -210
- data/lib/dicom/general/deprecated.rb +0 -320
- data/lib/dicom/general/logging.rb +155 -155
- data/lib/dicom/general/methods.rb +98 -82
- data/lib/dicom/general/variables.rb +28 -28
- data/lib/dicom/general/version.rb +5 -5
- data/lib/dicom/image_item.rb +836 -836
- data/lib/dicom/image_processor.rb +79 -79
- data/lib/dicom/image_processor_mini_magick.rb +71 -71
- data/lib/dicom/image_processor_r_magick.rb +106 -106
- data/lib/dicom/link.rb +1529 -1528
- data/rakefile.rb +29 -30
- metadata +43 -49
@@ -1,83 +1,99 @@
|
|
1
|
-
module DICOM
|
2
|
-
|
3
|
-
class << self
|
4
|
-
|
5
|
-
#--
|
6
|
-
# Module methods:
|
7
|
-
#++
|
8
|
-
|
9
|
-
# Generates a unique identifier string.
|
10
|
-
# The UID is composed of a DICOM root UID, a type prefix,
|
11
|
-
# a datetime part and a random number part.
|
12
|
-
#
|
13
|
-
# @param [String] root the DICOM root UID to be used for generating the UID string
|
14
|
-
# @param [String] prefix an integer string which is placed between the dicom root and the time/random part of the UID
|
15
|
-
# @return [String] the generated unique identifier
|
16
|
-
# @example Create a random UID with specified root and prefix
|
17
|
-
# uid = DICOM.generate_uid('1.2.840.999', '5')
|
18
|
-
#
|
19
|
-
def generate_uid(root=UID_ROOT, prefix=1)
|
20
|
-
# NB! For UIDs, leading zeroes immediately after a dot is not allowed.
|
21
|
-
date = Time.now.strftime("%Y%m%d").to_i.to_s
|
22
|
-
time = Time.now.strftime("%H%M%S").to_i.to_s
|
23
|
-
random = rand(99999) + 1 # (Minimum 1, max. 99999)
|
24
|
-
uid = [root, prefix, date, time, random].join('.')
|
25
|
-
return uid
|
26
|
-
end
|
27
|
-
|
28
|
-
# Loads DICOM data to DObject instances and returns them in an array.
|
29
|
-
# Invalid DICOM sources (files) are ignored.
|
30
|
-
# If no valid DICOM source is given, an empty array is returned.
|
31
|
-
#
|
32
|
-
# @param [String, DObject, Array<String, DObject>] data single or multiple DICOM data (directories, file paths, binary strings, DICOM objects)
|
33
|
-
# @return [Array<DObject>] an array of successfully loaded DICOM objects
|
34
|
-
#
|
35
|
-
def load(data)
|
36
|
-
data = Array[data] unless data.respond_to?(:to_ary)
|
37
|
-
ary = Array.new
|
38
|
-
data.each do |element|
|
39
|
-
if element.is_a?(String)
|
40
|
-
begin
|
41
|
-
if File.directory?(element)
|
42
|
-
files = Dir[File.join(element, '**/*')].reject {|f| File.directory?(f) }
|
43
|
-
dcms = files.collect {|f| DObject.read(f)}
|
44
|
-
elsif File.file?(element)
|
45
|
-
dcms = [DObject.read(element)]
|
46
|
-
else
|
47
|
-
dcms = [DObject.parse(element)]
|
48
|
-
end
|
49
|
-
rescue
|
50
|
-
dcms = [DObject.parse(element)]
|
51
|
-
end
|
52
|
-
ary += dcms.keep_if {|dcm| dcm.read?}
|
53
|
-
else
|
54
|
-
# The element was not a string, and the only remaining valid element type is a DICOM object:
|
55
|
-
raise ArgumentError, "Invalid element (#{element.class}) given. Expected string or DObject." unless element.respond_to?(:to_dcm)
|
56
|
-
element.was_dcm_on_input = true
|
57
|
-
ary << element.to_dcm
|
58
|
-
end
|
59
|
-
end
|
60
|
-
ary
|
61
|
-
end
|
62
|
-
|
63
|
-
#
|
64
|
-
#
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
1
|
+
module DICOM
|
2
|
+
|
3
|
+
class << self
|
4
|
+
|
5
|
+
#--
|
6
|
+
# Module methods:
|
7
|
+
#++
|
8
|
+
|
9
|
+
# Generates a unique identifier string.
|
10
|
+
# The UID is composed of a DICOM root UID, a type prefix,
|
11
|
+
# a datetime part and a random number part.
|
12
|
+
#
|
13
|
+
# @param [String] root the DICOM root UID to be used for generating the UID string
|
14
|
+
# @param [String] prefix an integer string which is placed between the dicom root and the time/random part of the UID
|
15
|
+
# @return [String] the generated unique identifier
|
16
|
+
# @example Create a random UID with specified root and prefix
|
17
|
+
# uid = DICOM.generate_uid('1.2.840.999', '5')
|
18
|
+
#
|
19
|
+
def generate_uid(root=UID_ROOT, prefix=1)
|
20
|
+
# NB! For UIDs, leading zeroes immediately after a dot is not allowed.
|
21
|
+
date = Time.now.strftime("%Y%m%d").to_i.to_s
|
22
|
+
time = Time.now.strftime("%H%M%S").to_i.to_s
|
23
|
+
random = rand(99999) + 1 # (Minimum 1, max. 99999)
|
24
|
+
uid = [root, prefix, date, time, random].join('.')
|
25
|
+
return uid
|
26
|
+
end
|
27
|
+
|
28
|
+
# Loads DICOM data to DObject instances and returns them in an array.
|
29
|
+
# Invalid DICOM sources (files) are ignored.
|
30
|
+
# If no valid DICOM source is given, an empty array is returned.
|
31
|
+
#
|
32
|
+
# @param [String, DObject, Array<String, DObject>] data single or multiple DICOM data (directories, file paths, binary strings, DICOM objects)
|
33
|
+
# @return [Array<DObject>] an array of successfully loaded DICOM objects
|
34
|
+
#
|
35
|
+
def load(data)
|
36
|
+
data = Array[data] unless data.respond_to?(:to_ary)
|
37
|
+
ary = Array.new
|
38
|
+
data.each do |element|
|
39
|
+
if element.is_a?(String)
|
40
|
+
begin
|
41
|
+
if File.directory?(element)
|
42
|
+
files = Dir[File.join(element, '**/*')].reject {|f| File.directory?(f) }
|
43
|
+
dcms = files.collect {|f| DObject.read(f)}
|
44
|
+
elsif File.file?(element)
|
45
|
+
dcms = [DObject.read(element)]
|
46
|
+
else
|
47
|
+
dcms = [DObject.parse(element)]
|
48
|
+
end
|
49
|
+
rescue
|
50
|
+
dcms = [DObject.parse(element)]
|
51
|
+
end
|
52
|
+
ary += dcms.keep_if {|dcm| dcm.read?}
|
53
|
+
else
|
54
|
+
# The element was not a string, and the only remaining valid element type is a DICOM object:
|
55
|
+
raise ArgumentError, "Invalid element (#{element.class}) given. Expected string or DObject." unless element.respond_to?(:to_dcm)
|
56
|
+
element.was_dcm_on_input = true
|
57
|
+
ary << element.to_dcm
|
58
|
+
end
|
59
|
+
end
|
60
|
+
ary
|
61
|
+
end
|
62
|
+
|
63
|
+
# Loads DICOM files from a given path (which points to either a directory or a file).
|
64
|
+
#
|
65
|
+
# @param [String] path a file or directory path
|
66
|
+
# @return [Array<String>] an array of individual file paths
|
67
|
+
#
|
68
|
+
def load_files(path)
|
69
|
+
files = Array.new
|
70
|
+
if File.directory?(path)
|
71
|
+
files = Dir[File.join(path, '**/*')].reject {|f| File.directory?(f) }
|
72
|
+
else
|
73
|
+
# This path is presumably a file.
|
74
|
+
files << path
|
75
|
+
end
|
76
|
+
files
|
77
|
+
end
|
78
|
+
|
79
|
+
# Use tags as key. Example: '0010,0010'
|
80
|
+
#
|
81
|
+
def key_use_tags
|
82
|
+
@key_representation = :tag
|
83
|
+
end
|
84
|
+
|
85
|
+
# Use names as key. Example: "Patient's Name"
|
86
|
+
#
|
87
|
+
def key_use_names
|
88
|
+
@key_representation = :name
|
89
|
+
end
|
90
|
+
|
91
|
+
# Use method names as key. Example: :patients_name
|
92
|
+
#
|
93
|
+
def key_use_method_names
|
94
|
+
@key_representation = :name_as_method
|
95
|
+
end
|
96
|
+
|
97
|
+
end
|
98
|
+
|
83
99
|
end
|
@@ -1,29 +1,29 @@
|
|
1
|
-
module DICOM
|
2
|
-
|
3
|
-
class << self
|
4
|
-
|
5
|
-
#--
|
6
|
-
# Module attributes:
|
7
|
-
#++
|
8
|
-
|
9
|
-
# The ruby-dicom image processor to be used.
|
10
|
-
attr_accessor :image_processor
|
11
|
-
# The key representation for hashes, json, yaml.
|
12
|
-
attr_accessor :key_representation
|
13
|
-
# Source Application Entity Title (gets written to the DICOM header in files where it is undefined).
|
14
|
-
attr_accessor :source_app_title
|
15
|
-
|
16
|
-
end
|
17
|
-
|
18
|
-
#--
|
19
|
-
# Default variable settings:
|
20
|
-
#++
|
21
|
-
|
22
|
-
# The default image processor.
|
23
|
-
self.image_processor = :rmagick
|
24
|
-
# The default key representation.
|
25
|
-
self.key_representation = :name
|
26
|
-
# The default source application entity title.
|
27
|
-
self.source_app_title = 'RUBY-DICOM'
|
28
|
-
|
1
|
+
module DICOM
|
2
|
+
|
3
|
+
class << self
|
4
|
+
|
5
|
+
#--
|
6
|
+
# Module attributes:
|
7
|
+
#++
|
8
|
+
|
9
|
+
# The ruby-dicom image processor to be used.
|
10
|
+
attr_accessor :image_processor
|
11
|
+
# The key representation for hashes, json, yaml.
|
12
|
+
attr_accessor :key_representation
|
13
|
+
# Source Application Entity Title (gets written to the DICOM header in files where it is undefined).
|
14
|
+
attr_accessor :source_app_title
|
15
|
+
|
16
|
+
end
|
17
|
+
|
18
|
+
#--
|
19
|
+
# Default variable settings:
|
20
|
+
#++
|
21
|
+
|
22
|
+
# The default image processor.
|
23
|
+
self.image_processor = :rmagick
|
24
|
+
# The default key representation.
|
25
|
+
self.key_representation = :name
|
26
|
+
# The default source application entity title.
|
27
|
+
self.source_app_title = 'RUBY-DICOM'
|
28
|
+
|
29
29
|
end
|
@@ -1,6 +1,6 @@
|
|
1
|
-
module DICOM
|
2
|
-
|
3
|
-
# The ruby-dicom version string.
|
4
|
-
VERSION = '0.9.
|
5
|
-
|
1
|
+
module DICOM
|
2
|
+
|
3
|
+
# The ruby-dicom version string.
|
4
|
+
VERSION = '0.9.7'
|
5
|
+
|
6
6
|
end
|
data/lib/dicom/image_item.rb
CHANGED
@@ -1,837 +1,837 @@
|
|
1
|
-
module DICOM
|
2
|
-
|
3
|
-
# Super class which contains common code for both the DObject and Item classes.
|
4
|
-
# This class includes the image related methods, since images may be stored either
|
5
|
-
# directly in the DObject, or in items (encapsulated items in the "Pixel Data"
|
6
|
-
# element or in "Icon Image Sequence" items).
|
7
|
-
#
|
8
|
-
# === Inheritance
|
9
|
-
#
|
10
|
-
# As the ImageItem class inherits from the Parent class, all Parent methods are
|
11
|
-
# also available to objects which has inherited ImageItem.
|
12
|
-
#
|
13
|
-
class ImageItem < Parent
|
14
|
-
|
15
|
-
include ImageProcessor
|
16
|
-
|
17
|
-
# Creates an Element with the given arguments and connects it to self.
|
18
|
-
#
|
19
|
-
# @param [String] tag an element tag
|
20
|
-
# @param [String, Integer, Float, Array, NilClass] value an element value
|
21
|
-
# @param [Hash] options any options used for creating the element (see Element.new documentation)
|
22
|
-
#
|
23
|
-
def add_element(tag, value, options={})
|
24
|
-
add(e = Element.new(tag, value, options))
|
25
|
-
e
|
26
|
-
end
|
27
|
-
|
28
|
-
# Creates a Sequence with the given arguments and connects it to self.
|
29
|
-
#
|
30
|
-
# @param [String] tag a sequence tag
|
31
|
-
# @param [Hash] options any options used for creating the sequence (see Sequence.new documentation)
|
32
|
-
#
|
33
|
-
def add_sequence(tag, options={})
|
34
|
-
add(s = Sequence.new(tag, options))
|
35
|
-
s
|
36
|
-
end
|
37
|
-
|
38
|
-
# Checks if colored pixel data is present.
|
39
|
-
#
|
40
|
-
# @return [Boolean] true if the object contains colored pixels, and false if not
|
41
|
-
#
|
42
|
-
def color?
|
43
|
-
# "Photometric Interpretation" is contained in the data element "0028,0004":
|
44
|
-
begin
|
45
|
-
photometric = photometry
|
46
|
-
if photometric.include?('COLOR') or photometric.include?('RGB') or photometric.include?('YBR')
|
47
|
-
return true
|
48
|
-
else
|
49
|
-
return false
|
50
|
-
end
|
51
|
-
rescue
|
52
|
-
return false
|
53
|
-
end
|
54
|
-
end
|
55
|
-
|
56
|
-
# Checks if compressed pixel data is present.
|
57
|
-
#
|
58
|
-
# @return [Boolean] true if the object contains compressed pixels, and false if not
|
59
|
-
#
|
60
|
-
def compression?
|
61
|
-
# If compression is used, the pixel data element is a Sequence (with encapsulated elements), instead of a Element:
|
62
|
-
if self[PIXEL_TAG].is_a?(Sequence)
|
63
|
-
return true
|
64
|
-
else
|
65
|
-
return false
|
66
|
-
end
|
67
|
-
end
|
68
|
-
|
69
|
-
# Unpacks pixel values from a binary pixel string. The decode is performed
|
70
|
-
# using values defined in the image related elements of the DObject instance.
|
71
|
-
#
|
72
|
-
# @param [String] bin a binary string containing the pixels to be decoded
|
73
|
-
# @param [Stream] stream a Stream instance to be used for decoding the pixels (optional)
|
74
|
-
# @return [Array<Integer>] decoded pixel values
|
75
|
-
#
|
76
|
-
def decode_pixels(bin, stream=@stream)
|
77
|
-
raise ArgumentError, "Expected String, got #{bin.class}." unless bin.is_a?(String)
|
78
|
-
pixels = false
|
79
|
-
# We need to know what kind of bith depth and integer type the pixel data is saved with:
|
80
|
-
bit_depth_element = self['0028,0100']
|
81
|
-
pixel_representation_element = self['0028,0103']
|
82
|
-
if bit_depth_element and pixel_representation_element
|
83
|
-
# Load the binary pixel data to the Stream instance:
|
84
|
-
stream.set_string(bin)
|
85
|
-
template = template_string(bit_depth_element.value.to_i)
|
86
|
-
pixels = stream.decode_all(template) if template
|
87
|
-
else
|
88
|
-
raise "The Element specifying Bit Depth (0028,0100) is missing. Unable to decode pixel data." unless bit_depth_element
|
89
|
-
raise "The Element specifying Pixel Representation (0028,0103) is missing. Unable to decode pixel data." unless pixel_representation_element
|
90
|
-
end
|
91
|
-
return pixels
|
92
|
-
end
|
93
|
-
|
94
|
-
# Packs a pixel value array to a binary pixel string. The encoding is performed
|
95
|
-
# using values defined in the image related elements of the DObject instance.
|
96
|
-
#
|
97
|
-
# @param [Array<Integer>] pixels an array containing the pixel values to be encoded
|
98
|
-
# @param [Stream] stream a Stream instance to be used for encoding the pixels (optional)
|
99
|
-
# @return [String] encoded pixel string
|
100
|
-
#
|
101
|
-
def encode_pixels(pixels, stream=@stream)
|
102
|
-
raise ArgumentError, "Expected Array, got #{pixels.class}." unless pixels.is_a?(Array)
|
103
|
-
bin = false
|
104
|
-
# We need to know what kind of bith depth and integer type the pixel data is saved with:
|
105
|
-
bit_depth_element = self['0028,0100']
|
106
|
-
pixel_representation_element = self['0028,0103']
|
107
|
-
if bit_depth_element and pixel_representation_element
|
108
|
-
template = template_string(bit_depth_element.value.to_i)
|
109
|
-
bin = stream.encode(pixels, template) if template
|
110
|
-
else
|
111
|
-
raise "The Element specifying Bit Depth (0028,0100) is missing. Unable to encode the pixel data." unless bit_depth_element
|
112
|
-
raise "The Element specifying Pixel Representation (0028,0103) is missing. Unable to encode the pixel data." unless pixel_representation_element
|
113
|
-
end
|
114
|
-
return bin
|
115
|
-
end
|
116
|
-
|
117
|
-
# Extracts a single image object, created from the encoded pixel data using
|
118
|
-
# the image related elements in the DICOM object. If the object contains multiple
|
119
|
-
# image frames, the first image frame is returned, unless the :frame option is used.
|
120
|
-
#
|
121
|
-
# @note Creates an image object in accordance with the selected image processor. Available processors are :rmagick and :mini_magick.
|
122
|
-
#
|
123
|
-
# @param [Hash] options the options to use for extracting the image
|
124
|
-
# @option options [Integer] :frame for DICOM objects containing multiple frames, this option can be used to extract a specific image frame (defaults to 0)
|
125
|
-
# @option options [TrueClass, Array<Integer>] :level if true, window leveling is performed using default values from the DICOM object, or if an array ([center, width]) is specified, these custom values are used instead
|
126
|
-
# @option options [Boolean] :narray if true, forces the use of NArray for the pixel remap process (for faster execution)
|
127
|
-
# @option options [Boolean] :remap if true, the returned pixel values are remapped to presentation values
|
128
|
-
# @return [MagickImage, NilClass, FalseClass] an image object, alternatively nil (if no image present) or false (if image decode failed)
|
129
|
-
#
|
130
|
-
# @example Retrieve pixel data as an RMagick image object and display it
|
131
|
-
# image = dcm.image
|
132
|
-
# image.display
|
133
|
-
# @example Retrieve frame index 5 in the pixel data
|
134
|
-
# image = dcm.image(:frame => 5)
|
135
|
-
#
|
136
|
-
def image(options={})
|
137
|
-
options[:frame] = options[:frame] || 0
|
138
|
-
image = images(options).first
|
139
|
-
image = false if image.nil? && exists?(PIXEL_TAG)
|
140
|
-
return image
|
141
|
-
end
|
142
|
-
|
143
|
-
# Extracts an array of image objects, created from the encoded pixel data using
|
144
|
-
# the image related elements in the DICOM object.
|
145
|
-
#
|
146
|
-
# @note Creates an array of image objects in accordance with the selected image processor. Available processors are :rmagick and :mini_magick.
|
147
|
-
#
|
148
|
-
# @param [Hash] options the options to use for extracting the images
|
149
|
-
# @option options [Integer] :frame makes the method return an array containing only the image object corresponding to the specified frame number
|
150
|
-
# @option options [TrueClass, Array<Integer>] :level if true, window leveling is performed using default values from the DICOM object, or if an array ([center, width]) is specified, these custom values are used instead
|
151
|
-
# @option options [Boolean] :narray if true, forces the use of NArray for the pixel remap process (for faster execution)
|
152
|
-
# @option options [Boolean] :remap if true, the returned pixel values are remapped to presentation values
|
153
|
-
# @return [Array<MagickImage, NilClass>] an array of image objects, alternatively an empty array (if no image present or image decode failed)
|
154
|
-
#
|
155
|
-
# @example Retrieve the pixel data as RMagick image objects
|
156
|
-
# images = dcm.images
|
157
|
-
# @example Retrieve the pixel data as RMagick image objects, remapped to presentation values (but without any leveling)
|
158
|
-
# images = dcm.images(:remap => true)
|
159
|
-
# @example Retrieve the pixel data as RMagick image objects, remapped to presentation values and leveled using the default center/width values in the DICOM object
|
160
|
-
# images = dcm.images(:level => true)
|
161
|
-
# @example Retrieve the pixel data as RMagick image objects, remapped to presentation values, leveled with the specified center/width values and using numerical array for the rescaling (~twice as fast)
|
162
|
-
# images = dcm.images(:level => [-200,1000], :narray => true)
|
163
|
-
#
|
164
|
-
def images(options={})
|
165
|
-
images = Array.new
|
166
|
-
if exists?(PIXEL_TAG)
|
167
|
-
# Gather the pixel data strings, and pick a single frame if indicated by options:
|
168
|
-
strings = image_strings(split_to_frames=true)
|
169
|
-
strings = [strings[options[:frame]]] if options[:frame]
|
170
|
-
if compression?
|
171
|
-
# Decompress, either to numbers (RLE) or to an image object (image based compressions):
|
172
|
-
if [TXS_RLE].include?(transfer_syntax)
|
173
|
-
pixel_frames = Array.new
|
174
|
-
strings.each {|string| pixel_frames << decode_rle(num_cols, num_rows, string)}
|
175
|
-
else
|
176
|
-
images = decompress(strings) || Array.new
|
177
|
-
logger.warn("Decompressing pixel values has failed (unsupported transfer syntax: '#{transfer_syntax}' - #{LIBRARY.uid(transfer_syntax) ? LIBRARY.uid(transfer_syntax).name : 'Unknown transfer syntax!'})") unless images.length > 0
|
178
|
-
end
|
179
|
-
else
|
180
|
-
# Uncompressed: Decode to numbers.
|
181
|
-
pixel_frames = Array.new
|
182
|
-
strings.each {|string| pixel_frames << decode_pixels(string)}
|
183
|
-
end
|
184
|
-
if pixel_frames
|
185
|
-
images = Array.new
|
186
|
-
pixel_frames.each do |pixels|
|
187
|
-
# Pixel values and pixel order may need to be rearranged if we have color data:
|
188
|
-
pixels = process_colors(pixels) if color?
|
189
|
-
if pixels
|
190
|
-
images << read_image(pixels, num_cols, num_rows, options)
|
191
|
-
else
|
192
|
-
logger.warn("Processing pixel values for this particular color mode failed, unable to construct image(s).")
|
193
|
-
end
|
194
|
-
end
|
195
|
-
end
|
196
|
-
end
|
197
|
-
return images
|
198
|
-
end
|
199
|
-
|
200
|
-
# Reads a binary string from a specified file and writes it to the value field of the pixel data element (7FE0,0010).
|
201
|
-
#
|
202
|
-
# @param [String] file a string which specifies the path of the file containing pixel data
|
203
|
-
#
|
204
|
-
# @example Load pixel data from a file
|
205
|
-
# dcm.image_from_file("custom_image.dat")
|
206
|
-
#
|
207
|
-
def image_from_file(file)
|
208
|
-
raise ArgumentError, "Expected #{String}, got #{file.class}." unless file.is_a?(String)
|
209
|
-
f = File.new(file, 'rb')
|
210
|
-
bin = f.read(f.stat.size)
|
211
|
-
if bin.length > 0
|
212
|
-
# Write the binary data to the Pixel Data Element:
|
213
|
-
write_pixels(bin)
|
214
|
-
else
|
215
|
-
logger.info("The specified file (#{file}) is empty. Nothing to transfer.")
|
216
|
-
end
|
217
|
-
end
|
218
|
-
|
219
|
-
# Extracts the pixel data binary string(s) in an array.
|
220
|
-
#
|
221
|
-
# @param [Boolean] split if true, a pixel data string containing 3D volumetric data will be split into N substrings (where N equals the number of frames)
|
222
|
-
# @return [Array<String, NilClass>] an array of pixel data strings, or an empty array (if no pixel data present)
|
223
|
-
#
|
224
|
-
def image_strings(split=false)
|
225
|
-
# Pixel data may be a single binary string in the pixel data element,
|
226
|
-
# or located in several encapsulated item elements:
|
227
|
-
pixel_element = self[PIXEL_TAG]
|
228
|
-
strings = Array.new
|
229
|
-
if pixel_element.is_a?(Element)
|
230
|
-
if split
|
231
|
-
strings = pixel_element.bin.dup.divide(num_frames)
|
232
|
-
else
|
233
|
-
strings << pixel_element.bin
|
234
|
-
end
|
235
|
-
elsif pixel_element.is_a?(Sequence)
|
236
|
-
pixel_items = pixel_element.children.first.children
|
237
|
-
pixel_items.each {|item| strings << item.bin}
|
238
|
-
end
|
239
|
-
return strings
|
240
|
-
end
|
241
|
-
|
242
|
-
# Dumps the binary content of the Pixel Data element to the specified file.
|
243
|
-
#
|
244
|
-
# If the DICOM object contains multi-fragment pixel data, each fragment
|
245
|
-
# will be dumped to separate files (e.q. 'fragment-0.dat', 'fragment-1.dat').
|
246
|
-
#
|
247
|
-
# @param [String] file a string which specifies the file path to use when dumping the pixel data
|
248
|
-
# @example Dumping the pixel data to a file
|
249
|
-
# dcm.image_to_file("exported_image.dat")
|
250
|
-
#
|
251
|
-
def image_to_file(file)
|
252
|
-
raise ArgumentError, "Expected #{String}, got #{file.class}." unless file.is_a?(String)
|
253
|
-
# Split the file name in case of multiple fragments:
|
254
|
-
parts = file.split('.')
|
255
|
-
if parts.length > 1
|
256
|
-
base = parts[0..-2].join
|
257
|
-
extension = '.' + parts.last
|
258
|
-
else
|
259
|
-
base = file
|
260
|
-
extension = ''
|
261
|
-
end
|
262
|
-
# Get the binary image strings and dump them to the file(s):
|
263
|
-
images = image_strings
|
264
|
-
images.each_index do |i|
|
265
|
-
if images.length == 1
|
266
|
-
f = File.new(file, 'wb')
|
267
|
-
else
|
268
|
-
f = File.new("#{base}-#{i}#{extension}", 'wb')
|
269
|
-
end
|
270
|
-
f.write(images[i])
|
271
|
-
f.close
|
272
|
-
end
|
273
|
-
end
|
274
|
-
|
275
|
-
# Encodes pixel data from a (Magick) image object and writes it to the
|
276
|
-
# pixel data element (7FE0,0010).
|
277
|
-
#
|
278
|
-
# Because of pixel value issues related to image objects (images don't like
|
279
|
-
# signed integers), and the possible difference between presentation values
|
280
|
-
# and raw pixel values, the use of image=() may result in pixel data where the
|
281
|
-
# integer values differs somewhat from what is expected. Use with care! For
|
282
|
-
# precise pixel value processing, use the Array and NArray based pixel data methods instead.
|
283
|
-
#
|
284
|
-
# @param [MagickImage] image the image to be assigned to the pixel data element
|
285
|
-
#
|
286
|
-
def image=(image)
|
287
|
-
raise ArgumentError, "Expected one of the supported image classes: #{valid_image_objects} (got #{image.class})" unless valid_image_objects.include?(image.class.to_s)
|
288
|
-
# Export to pixels using the proper image processor:
|
289
|
-
pixels = export_pixels(image, photometry)
|
290
|
-
# Encode and write to the Pixel Data Element:
|
291
|
-
self.pixels = pixels
|
292
|
-
end
|
293
|
-
|
294
|
-
# Gives the number of columns in the pixel data.
|
295
|
-
#
|
296
|
-
# @return [Integer, NilClass] the number of columns, or nil (if the columns value is undefined)
|
297
|
-
#
|
298
|
-
def num_cols
|
299
|
-
self['0028,0011'].value rescue nil
|
300
|
-
end
|
301
|
-
|
302
|
-
# Gives the number of frames in the pixel data.
|
303
|
-
#
|
304
|
-
# @note Assumes and gives 1 if the number of frames value is not defined.
|
305
|
-
# @return [Integer] the number of rows
|
306
|
-
#
|
307
|
-
def num_frames
|
308
|
-
(self['0028,0008'].is_a?(Element) == true ? self['0028,0008'].value.to_i : 1)
|
309
|
-
end
|
310
|
-
|
311
|
-
# Gives the number of rows in the pixel data.
|
312
|
-
#
|
313
|
-
# @return [Integer, NilClass] the number of rows, or nil (if the rows value is undefined)
|
314
|
-
#
|
315
|
-
def num_rows
|
316
|
-
self['0028,0010'].value rescue nil
|
317
|
-
end
|
318
|
-
|
319
|
-
# Creates an NArray containing the pixel data. If the pixel data is an image
|
320
|
-
# (single frame), a 2-dimensional NArray is returned [columns, rows]. If the
|
321
|
-
# pixel data is 3-dimensional (more than one frame), a 3-dimensional NArray
|
322
|
-
# is returned [frames, columns, rows].
|
323
|
-
#
|
324
|
-
# @note To call this method you need to have loaded the NArray library in advance (require 'narray').
|
325
|
-
#
|
326
|
-
# @param [Hash] options the options to use for extracting the pixel data
|
327
|
-
# @option options [TrueClass, Array<Integer>] :level if true, window leveling is performed using default values from the DICOM object, or if an array ([center, width]) is specified, these custom values are used instead
|
328
|
-
# @option options [Boolean] :remap if true, the returned pixel values are remapped to presentation values
|
329
|
-
# @option options [Boolean] :volume if true, the returned array will always be 3-dimensional, even if the pixel data only has one frame
|
330
|
-
# @return [NArray, NilClass, FalseClass] an NArray of pixel values, alternatively nil (if no image present) or false (if image decode failed)
|
331
|
-
#
|
332
|
-
# @example Retrieve numerical pixel array
|
333
|
-
# data = dcm.narray
|
334
|
-
# @example Retrieve numerical pixel array remapped from the original pixel values to presentation values
|
335
|
-
# data = dcm.narray(:remap => true)
|
336
|
-
#
|
337
|
-
def narray(options={})
|
338
|
-
pixels = nil
|
339
|
-
if exists?(PIXEL_TAG)
|
340
|
-
unless color?
|
341
|
-
# Decode the pixel values: For now we only support returning pixel data of the first frame (if the image is located in multiple pixel data items).
|
342
|
-
if compression?
|
343
|
-
pixels = decompress(image_strings.first)
|
344
|
-
else
|
345
|
-
pixels = decode_pixels(image_strings.first)
|
346
|
-
end
|
347
|
-
if pixels
|
348
|
-
# Import the pixels to NArray and give it a proper shape:
|
349
|
-
raise "Missing Rows and/or Columns Element. Unable to construct pixel data array." unless num_rows and num_cols
|
350
|
-
if num_frames > 1 or options[:volume]
|
351
|
-
# Create an empty 3D NArray. fill it with pixels frame by frame, then reassign the pixels variable to it:
|
352
|
-
narr = NArray.int(num_frames, num_cols, num_rows)
|
353
|
-
num_frames.times do |i|
|
354
|
-
narr[i, true, true] = NArray.to_na(pixels[(i * num_cols * num_rows)..((i + 1) * num_cols * num_rows - 1)]).reshape!(num_cols, num_rows)
|
355
|
-
end
|
356
|
-
pixels = narr
|
357
|
-
else
|
358
|
-
pixels = NArray.to_na(pixels).reshape!(num_cols, num_rows)
|
359
|
-
end
|
360
|
-
# Remap the image from pixel values to presentation values if the user has requested this:
|
361
|
-
pixels = process_presentation_values_narray(pixels, -65535, 65535, options[:level]) if options[:remap] or options[:level]
|
362
|
-
else
|
363
|
-
logger.warn("Decompressing the Pixel Data failed. Pixel values can not be extracted.")
|
364
|
-
end
|
365
|
-
else
|
366
|
-
logger.warn("The DICOM object contains colored pixel data. Retrieval of colored pixels is not supported by this method yet.")
|
367
|
-
pixels = false
|
368
|
-
end
|
369
|
-
end
|
370
|
-
return pixels
|
371
|
-
end
|
372
|
-
|
373
|
-
# Extracts the Pixel Data values in an ordinary Ruby Array.
|
374
|
-
# Returns nil if no pixel data is present, and false if it fails to retrieve pixel data which is present.
|
375
|
-
#
|
376
|
-
# The returned array does not carry the dimensions of the pixel data:
|
377
|
-
# It is put in a one dimensional Array (vector).
|
378
|
-
#
|
379
|
-
# @param [Hash] options the options to use for extracting the pixel data
|
380
|
-
# @option options [TrueClass, Array<Integer>] :level if true, window leveling is performed using default values from the DICOM object, or if an array ([center, width]) is specified, these custom values are used instead
|
381
|
-
# @option options [Boolean] :narray if true, forces the use of NArray for the pixel remap process (for faster execution)
|
382
|
-
# @option options [Boolean] :remap if true, the returned pixel values are remapped to presentation values
|
383
|
-
# @return [Array, NilClass, FalseClass] an Array of pixel values, alternatively nil (if no image present) or false (if image decode failed)
|
384
|
-
#
|
385
|
-
# @example Simply retrieve the pixel data
|
386
|
-
# pixels = dcm.pixels
|
387
|
-
# @example Retrieve the pixel data remapped to presentation values according to window center/width settings
|
388
|
-
# pixels = dcm.pixels(:remap => true)
|
389
|
-
# @example Retrieve the remapped pixel data while using numerical array (~twice as fast)
|
390
|
-
# pixels = dcm.pixels(:remap => true, :narray => true)
|
391
|
-
#
|
392
|
-
def pixels(options={})
|
393
|
-
pixels = nil
|
394
|
-
if exists?(PIXEL_TAG)
|
395
|
-
# For now we only support returning pixel data of the first frame, if the image is located in multiple pixel data items:
|
396
|
-
if compression?
|
397
|
-
pixels = decompress(image_strings.first)
|
398
|
-
else
|
399
|
-
pixels = decode_pixels(image_strings.first)
|
400
|
-
end
|
401
|
-
if pixels
|
402
|
-
# Remap the image from pixel values to presentation values if the user has requested this:
|
403
|
-
if options[:remap] or options[:level]
|
404
|
-
if options[:narray]
|
405
|
-
# Use numerical array (faster):
|
406
|
-
pixels = process_presentation_values_narray(pixels, -65535, 65535, options[:level]).to_a
|
407
|
-
else
|
408
|
-
# Use standard Ruby array (slower):
|
409
|
-
pixels = process_presentation_values(pixels, -65535, 65535, options[:level])
|
410
|
-
end
|
411
|
-
end
|
412
|
-
else
|
413
|
-
logger.warn("Decompressing the Pixel Data failed. Pixel values can not be extracted.")
|
414
|
-
end
|
415
|
-
end
|
416
|
-
return pixels
|
417
|
-
end
|
418
|
-
|
419
|
-
# Encodes pixel data from a Ruby Array or NArray, and writes it to the pixel data element (7FE0,0010).
|
420
|
-
#
|
421
|
-
# @param [Array<Integer>, NArray] values an Array (or NArray) containing integer pixel values
|
422
|
-
#
|
423
|
-
def pixels=(values)
|
424
|
-
raise ArgumentError, "The given argument does not respond to #to_a (got an argument of class #{values.class})" unless values.respond_to?(:to_a)
|
425
|
-
if values.class.ancestors.to_s.include?('NArray')
|
426
|
-
# With an NArray argument, make sure that it gets properly converted to an Array:
|
427
|
-
if values.shape.length > 2
|
428
|
-
# For a 3D NArray we need to rearrange to ensure that the pixels get their
|
429
|
-
# proper order when converting to an ordinary Array instance:
|
430
|
-
narr = NArray.int(values.shape[1] * values.shape[2], values.shape[0])
|
431
|
-
values.shape[0].times do |i|
|
432
|
-
narr[true, i] = values[i, true, true].reshape(values.shape[1] * values.shape[2])
|
433
|
-
end
|
434
|
-
values = narr
|
435
|
-
end
|
436
|
-
end
|
437
|
-
# Encode the pixel data:
|
438
|
-
bin = encode_pixels(values.to_a.flatten)
|
439
|
-
# Write the binary data to the Pixel Data Element:
|
440
|
-
write_pixels(bin)
|
441
|
-
end
|
442
|
-
|
443
|
-
# Delete all Sequence instances from the DObject or Item instance.
|
444
|
-
#
|
445
|
-
def delete_sequences
|
446
|
-
@tags.each_value do |element|
|
447
|
-
delete(element.tag) if element.is_a?(Sequence)
|
448
|
-
end
|
449
|
-
end
|
450
|
-
|
451
|
-
|
452
|
-
private
|
453
|
-
|
454
|
-
|
455
|
-
# Gives the effective bit depth of the pixel data (considers a special case
|
456
|
-
# for Palette colored images).
|
457
|
-
#
|
458
|
-
# @raise [RuntimeError] if the 'Bits Allocated' element is missing
|
459
|
-
# @return [Integer] the effective bit depth of the pixel data
|
460
|
-
#
|
461
|
-
def actual_bit_depth
|
462
|
-
raise "The 'Bits Allocated' Element is missing from this DICOM instance. Unable to encode/decode pixel data." unless exists?("0028,0100")
|
463
|
-
if photometry == PI_PALETTE_COLOR
|
464
|
-
# Only one channel is checked and it is assumed that all channels have the same number of bits.
|
465
|
-
return self['0028,1101'].value.split("\\").last.to_i
|
466
|
-
else
|
467
|
-
return bit_depth
|
468
|
-
end
|
469
|
-
end
|
470
|
-
|
471
|
-
|
472
|
-
# Gives the value from the "Bits Allocated" Element.
|
473
|
-
#
|
474
|
-
# @raise [RuntimeError] if the 'Bits Allocated' element is missing
|
475
|
-
# @return [Integer] the number of bits allocated
|
476
|
-
#
|
477
|
-
def bit_depth
|
478
|
-
raise "The 'Bits Allocated' Element is missing from this DICOM instance. Unable to encode/decode pixel data." unless exists?("0028,0100")
|
479
|
-
return value('0028,0100')
|
480
|
-
end
|
481
|
-
|
482
|
-
# Performs a run length decoding on the input stream.
|
483
|
-
#
|
484
|
-
# @note For details on RLE encoding, refer to the DICOM standard, PS3.5, Section 8.2.2 as well as Annex G.
|
485
|
-
#
|
486
|
-
# @param [Integer] cols number of colums of the encoded image
|
487
|
-
# @param [Integer] rows number of rows of the encoded image
|
488
|
-
# @param [Integer] string the encoded pixel string
|
489
|
-
# @return [Array<Integer>] the decoded pixel values
|
490
|
-
#
|
491
|
-
def decode_rle(cols, rows, string)
|
492
|
-
# FIXME: Remove cols and rows (were only added for debugging).
|
493
|
-
pixels = Array.new
|
494
|
-
# RLE header specifying the number of segments:
|
495
|
-
header = string[0...64].unpack('L*')
|
496
|
-
image_segments = Array.new
|
497
|
-
# Extracting all start and endpoints of the different segments:
|
498
|
-
header.each_index do |n|
|
499
|
-
if n == 0
|
500
|
-
# This one need no processing.
|
501
|
-
elsif n == header[0]
|
502
|
-
# It's the last one
|
503
|
-
image_segments << [header[n], -1]
|
504
|
-
break
|
505
|
-
else
|
506
|
-
image_segments << [header[n], header[n + 1] - 1]
|
507
|
-
end
|
508
|
-
end
|
509
|
-
# Iterate over each segment and extract pixel data:
|
510
|
-
image_segments.each do |range|
|
511
|
-
segment_data = Array.new
|
512
|
-
next_bytes = -1
|
513
|
-
next_multiplier = 0
|
514
|
-
# Iterate this segment's pixel string:
|
515
|
-
string[range[0]..range[1]].each_byte do |b|
|
516
|
-
if next_multiplier > 0
|
517
|
-
next_multiplier.times { segment_data << b }
|
518
|
-
next_multiplier = 0
|
519
|
-
elsif next_bytes > 0
|
520
|
-
segment_data << b
|
521
|
-
next_bytes -= 1
|
522
|
-
elsif b <= 127
|
523
|
-
next_bytes = b + 1
|
524
|
-
else
|
525
|
-
# Explaining the 257 at this point is a little bit complicate. Basically it has something
|
526
|
-
# to do with the algorithm described in the DICOM standard and that the value -1 as uint8 is 255.
|
527
|
-
# TODO: Is this architectur safe or does it only work on Intel systems???
|
528
|
-
next_multiplier = 257 - b
|
529
|
-
end
|
530
|
-
end
|
531
|
-
# Verify that the RLE decoding has executed properly:
|
532
|
-
throw "Size mismatch #{segment_data.size} != #{rows * cols}" if segment_data.size != rows * cols
|
533
|
-
pixels += segment_data
|
534
|
-
end
|
535
|
-
return pixels
|
536
|
-
end
|
537
|
-
|
538
|
-
# Gives the value from the "Photometric Interpretation" Element.
|
539
|
-
#
|
540
|
-
# @raise [RuntimeError] if the 'Photometric Interpretation' element is missing
|
541
|
-
# @return [String] the photometric interpretation
|
542
|
-
#
|
543
|
-
def photometry
|
544
|
-
raise "The 'Photometric Interpretation' Element is missing from this DICOM instance. Unable to encode/decode pixel data." unless exists?("0028,0004")
|
545
|
-
return value('0028,0004').upcase
|
546
|
-
end
|
547
|
-
|
548
|
-
# Processes the pixel array based on attributes defined in the DICOM object,
|
549
|
-
#to produce a pixel array with correct pixel colors (RGB) as well as pixel
|
550
|
-
# order (RGB-pixel1, RGB-pixel2, etc). The relevant DICOM tags are
|
551
|
-
# Photometric Interpretation (0028,0004) and Planar Configuration (0028,0006).
|
552
|
-
#
|
553
|
-
# @param [Array<Integer>] pixels an array of (unsorted) color pixel values
|
554
|
-
# @return [Array<Integer>] an array of properly (sorted) color pixel values
|
555
|
-
#
|
556
|
-
def process_colors(pixels)
|
557
|
-
proper_rgb = false
|
558
|
-
photometric = photometry()
|
559
|
-
# (With RLE COLOR PALETTE the Planar Configuration is not set)
|
560
|
-
planar = self['0028,0006'].is_a?(Element) ? self['0028,0006'].value : 0
|
561
|
-
# Step 1: Produce an array with RGB values. At this time, YBR is not supported in ruby-dicom,
|
562
|
-
# so this leaves us with a possible conversion from PALETTE COLOR:
|
563
|
-
if photometric.include?('COLOR')
|
564
|
-
# Pseudo colors (rgb values grabbed from a lookup table):
|
565
|
-
rgb = Array.new(pixels.length*3)
|
566
|
-
# Prepare the lookup data arrays:
|
567
|
-
lookup_binaries = [self['0028,1201'].bin, self['0028,1202'].bin, self['0028,1203'].bin]
|
568
|
-
lookup_values = Array.new
|
569
|
-
nr_bits = self['0028,1101'].value.split("\\").last.to_i
|
570
|
-
template = template_string(nr_bits)
|
571
|
-
lookup_binaries.each do |bin|
|
572
|
-
stream.set_string(bin)
|
573
|
-
lookup_values << stream.decode_all(template)
|
574
|
-
end
|
575
|
-
lookup_values = lookup_values.transpose
|
576
|
-
# Fill the RGB array, one RGB pixel group (3 pixels) at a time:
|
577
|
-
pixels.each_index do |i|
|
578
|
-
rgb[i*3, 3] = lookup_values[pixels[i]]
|
579
|
-
end
|
580
|
-
# As we have now ordered the pixels in RGB order, modify planar configuration to reflect this:
|
581
|
-
planar = 0
|
582
|
-
elsif photometric.include?('YBR')
|
583
|
-
rgb = false
|
584
|
-
else
|
585
|
-
rgb = pixels
|
586
|
-
end
|
587
|
-
# Step 2: If indicated by the planar configuration, the order of the pixels need to be rearranged:
|
588
|
-
if rgb
|
589
|
-
if planar == 1
|
590
|
-
# Rearrange from [RRR...GGG....BBB...] to [(RGB)(RGB)(RGB)...]:
|
591
|
-
r_ind = [rgb.length/3-1, rgb.length*2/3-1, rgb.length-1]
|
592
|
-
l_ind = [0, rgb.length/3, rgb.length*2/3]
|
593
|
-
proper_rgb = [rgb[l_ind[0]..r_ind[0]], rgb[l_ind[1]..r_ind[1]], rgb[l_ind[2]..r_ind[2]]].transpose.flatten
|
594
|
-
else
|
595
|
-
proper_rgb = rgb
|
596
|
-
end
|
597
|
-
end
|
598
|
-
return proper_rgb
|
599
|
-
end
|
600
|
-
|
601
|
-
# Converts original pixel data values to presentation values.
|
602
|
-
#
|
603
|
-
# @param [Array<Integer>] pixel_data an array of pixel values (integers)
|
604
|
-
# @param [Integer] min_allowed the minimum value allowed in the pixel data
|
605
|
-
# @param [Integer] max_allowed the maximum value allowed in the pixel data
|
606
|
-
# @param [Boolean, Array<Integer>] level if true, window leveling is performed using default values from the DICOM object, or if an array ([center, width]) is specified, these custom values are used instead
|
607
|
-
# @return [Array<Integer>] presentation values
|
608
|
-
#
|
609
|
-
def process_presentation_values(pixel_data, min_allowed, max_allowed, level=nil)
|
610
|
-
# Process pixel data for presentation according to the image information in the DICOM object:
|
611
|
-
center, width, intercept, slope = window_level_values
|
612
|
-
# Have image leveling been requested?
|
613
|
-
if level
|
614
|
-
# If custom values are specified in an array, use those. If not, the default values from the DICOM object are used:
|
615
|
-
if level.is_a?(Array)
|
616
|
-
center = level[0]
|
617
|
-
width = level[1]
|
618
|
-
end
|
619
|
-
else
|
620
|
-
center, width = false, false
|
621
|
-
end
|
622
|
-
# PixelOutput = slope * pixel_values + intercept
|
623
|
-
if intercept != 0 or slope != 1
|
624
|
-
pixel_data.collect!{|x| (slope * x) + intercept}
|
625
|
-
end
|
626
|
-
# Contrast enhancement by black and white thresholding:
|
627
|
-
if center and width
|
628
|
-
low = center - width/2
|
629
|
-
high = center + width/2
|
630
|
-
pixel_data.each_index do |i|
|
631
|
-
if pixel_data[i] < low
|
632
|
-
pixel_data[i] = low
|
633
|
-
elsif pixel_data[i] > high
|
634
|
-
pixel_data[i] = high
|
635
|
-
end
|
636
|
-
end
|
637
|
-
end
|
638
|
-
# Need to introduce an offset?
|
639
|
-
min_pixel_value = pixel_data.min
|
640
|
-
if min_allowed
|
641
|
-
if min_pixel_value < min_allowed
|
642
|
-
offset = min_pixel_value.abs
|
643
|
-
pixel_data.collect!{|x| x + offset}
|
644
|
-
end
|
645
|
-
end
|
646
|
-
# Downscale pixel range?
|
647
|
-
max_pixel_value = pixel_data.max
|
648
|
-
if max_allowed
|
649
|
-
if max_pixel_value > max_allowed
|
650
|
-
factor = (max_pixel_value.to_f/max_allowed.to_f).ceil
|
651
|
-
pixel_data.collect!{|x| x / factor}
|
652
|
-
end
|
653
|
-
end
|
654
|
-
return pixel_data
|
655
|
-
end
|
656
|
-
|
657
|
-
# Converts original pixel data values to presentation values, using the efficient NArray library.
|
658
|
-
#
|
659
|
-
# @note If a Ruby Array is supplied, the method returns a one-dimensional NArray object (i.e. no columns & rows).
|
660
|
-
# @note If a NArray is supplied, the NArray is returned with its original dimensions.
|
661
|
-
#
|
662
|
-
# @param [Array<Integer>, NArray] pixel_data pixel values
|
663
|
-
# @param [Integer] min_allowed the minimum value allowed in the pixel data
|
664
|
-
# @param [Integer] max_allowed the maximum value allowed in the pixel data
|
665
|
-
# @param [Boolean, Array<Integer>] level if true, window leveling is performed using default values from the DICOM object, or if an array ([center, width]) is specified, these custom values are used instead
|
666
|
-
# @return [Array<Integer>, NArray] presentation values
|
667
|
-
#
|
668
|
-
def process_presentation_values_narray(pixel_data, min_allowed, max_allowed, level=nil)
|
669
|
-
# Process pixel data for presentation according to the image information in the DICOM object:
|
670
|
-
center, width, intercept, slope = window_level_values
|
671
|
-
# Have image leveling been requested?
|
672
|
-
if level
|
673
|
-
# If custom values are specified in an array, use those. If not, the default values from the DICOM object are used:
|
674
|
-
if level.is_a?(Array)
|
675
|
-
center = level[0]
|
676
|
-
width = level[1]
|
677
|
-
end
|
678
|
-
else
|
679
|
-
center, width = false, false
|
680
|
-
end
|
681
|
-
# Need to convert to NArray?
|
682
|
-
if pixel_data.is_a?(Array)
|
683
|
-
n_arr = NArray.to_na(pixel_data)
|
684
|
-
else
|
685
|
-
n_arr = pixel_data
|
686
|
-
end
|
687
|
-
# Remap:
|
688
|
-
# PixelOutput = slope * pixel_values + intercept
|
689
|
-
if intercept != 0 or slope != 1
|
690
|
-
n_arr = slope * n_arr + intercept
|
691
|
-
end
|
692
|
-
# Contrast enhancement by black and white thresholding:
|
693
|
-
if center and width
|
694
|
-
low = center - width/2
|
695
|
-
high = center + width/2
|
696
|
-
n_arr[n_arr < low] = low
|
697
|
-
n_arr[n_arr > high] = high
|
698
|
-
end
|
699
|
-
# Need to introduce an offset?
|
700
|
-
min_pixel_value = n_arr.min
|
701
|
-
if min_allowed
|
702
|
-
if min_pixel_value < min_allowed
|
703
|
-
offset = min_pixel_value.abs
|
704
|
-
n_arr = n_arr + offset
|
705
|
-
end
|
706
|
-
end
|
707
|
-
# Downscale pixel range?
|
708
|
-
max_pixel_value = n_arr.max
|
709
|
-
if max_allowed
|
710
|
-
if max_pixel_value > max_allowed
|
711
|
-
factor = (max_pixel_value.to_f/max_allowed.to_f).ceil
|
712
|
-
n_arr = n_arr / factor
|
713
|
-
end
|
714
|
-
end
|
715
|
-
return n_arr
|
716
|
-
end
|
717
|
-
|
718
|
-
# Creates an image object from the specified pixel value array, performing
|
719
|
-
# presentation value processing if requested.
|
720
|
-
#
|
721
|
-
# @note Definitions for Window Center and Width can be found in the DICOM standard, PS 3.3 C.11.2.1.2
|
722
|
-
#
|
723
|
-
# @param [Array<Integer>] pixel_data an array of pixel values
|
724
|
-
# @param [Integer] columns the number of columns in the pixel data
|
725
|
-
# @param [Integer] rows the number of rows in the pixel data
|
726
|
-
# @param [Hash] options the options to use for reading the image
|
727
|
-
# @option options [Boolean] :remap if true, pixel values are remapped to presentation values (using intercept and slope values from the DICOM object)
|
728
|
-
# @option options [Boolean, Array<Integer>] :level if true, window leveling is performed using default values from the DICOM object, or if an array ([center, width]) is specified, these custom values are used instead
|
729
|
-
# @option options [Boolean] :narray if true, forces the use of NArray for the pixel remap process (for faster execution)
|
730
|
-
# @return [MagickImage] the extracted image object
|
731
|
-
#
|
732
|
-
def read_image(pixel_data, columns, rows, options={})
|
733
|
-
raise ArgumentError, "Expected Array for pixel_data, got #{pixel_data.class}" unless pixel_data.is_a?(Array)
|
734
|
-
raise ArgumentError, "Expected Integer for columns, got #{columns.class}" unless columns.is_a?(Integer)
|
735
|
-
raise ArgumentError, "Expected Rows for columns, got #{rows.class}" unless rows.is_a?(Integer)
|
736
|
-
raise ArgumentError, "Size of pixel_data must be at least equal to columns*rows. Got #{columns}*#{rows}=#{columns*rows}, which is less than the array size #{pixel_data.length}" if columns * rows > pixel_data.length
|
737
|
-
# Remap the image from pixel values to presentation values if the user has requested this:
|
738
|
-
if options[:remap] or options[:level]
|
739
|
-
# How to perform the remapping? NArray (fast) or Ruby Array (slow)?
|
740
|
-
if options[:narray] == true
|
741
|
-
pixel_data = process_presentation_values_narray(pixel_data, 0, 65535, options[:level]).to_a
|
742
|
-
else
|
743
|
-
pixel_data = process_presentation_values(pixel_data, 0, 65535, options[:level])
|
744
|
-
end
|
745
|
-
else
|
746
|
-
# No remapping, but make sure that we pass on unsigned pixel values to the image processor:
|
747
|
-
pixel_data = pixel_data.to_unsigned(bit_depth) if signed_pixels?
|
748
|
-
end
|
749
|
-
image = import_pixels(pixel_data.to_blob(actual_bit_depth), columns, rows, actual_bit_depth, photometry)
|
750
|
-
return image
|
751
|
-
end
|
752
|
-
|
753
|
-
# Checks if the Pixel Representation indicates signed pixel values or not.
|
754
|
-
#
|
755
|
-
# @raise [RuntimeError] if the 'Pixel Representation' element is missing
|
756
|
-
# @return [Boolean] true if pixel values are signed, false if not
|
757
|
-
#
|
758
|
-
def signed_pixels?
|
759
|
-
raise "The 'Pixel Representation' data element is missing from this DICOM instance. Unable to process pixel data." unless exists?("0028,0103")
|
760
|
-
case value('0028,0103')
|
761
|
-
when 1
|
762
|
-
return true
|
763
|
-
when 0
|
764
|
-
return false
|
765
|
-
else
|
766
|
-
raise "Invalid value encountered (#{value('0028,0103')}) in the 'Pixel Representation' data element. Expected 0 or 1."
|
767
|
-
end
|
768
|
-
end
|
769
|
-
|
770
|
-
# Determines the template/format string for pack/unpacking pixel data, based on
|
771
|
-
# the number of bits per pixel as well as the pixel representation (signed or unsigned).
|
772
|
-
#
|
773
|
-
# @param [Integer] depth the number of allocated bits in the integers to be decoded/encoded
|
774
|
-
# @return [String] a format string
|
775
|
-
#
|
776
|
-
def template_string(depth)
|
777
|
-
template = false
|
778
|
-
pixel_representation = self['0028,0103'].value.to_i
|
779
|
-
# Number of bytes used per pixel will determine how to unpack this:
|
780
|
-
case depth
|
781
|
-
when 8 # (1 byte)
|
782
|
-
template = 'BY' # Byte/Character/Fixnum
|
783
|
-
when 16 # (2 bytes)
|
784
|
-
if pixel_representation == 1
|
785
|
-
template = 'SS' # Signed short
|
786
|
-
else
|
787
|
-
template = 'US' # Unsigned short
|
788
|
-
end
|
789
|
-
when 32 # (4 bytes)
|
790
|
-
if pixel_representation == 1
|
791
|
-
template = 'SL' # Signed long
|
792
|
-
else
|
793
|
-
template = 'UL' # Unsigned long
|
794
|
-
end
|
795
|
-
when 12
|
796
|
-
# 12 BIT SIMPLY NOT IMPLEMENTED YET!
|
797
|
-
# This one is a bit tricky. I havent really given this priority so far as 12 bit image data is rather rare.
|
798
|
-
raise "Packing/unpacking pixel data of bit depth 12 is not implemented yet! Please contact the author (or edit the source code)."
|
799
|
-
else
|
800
|
-
raise ArgumentError, "Encoding/Decoding pixel data with this Bit Depth (#{depth}) is not implemented."
|
801
|
-
end
|
802
|
-
return template
|
803
|
-
end
|
804
|
-
|
805
|
-
# Collects the window level values needed to convert the original pixel
|
806
|
-
# values to presentation values.
|
807
|
-
#
|
808
|
-
# @note If some of these values are missing in the DObject instance,
|
809
|
-
# default values are used instead for intercept and slope, while center
|
810
|
-
# and width are set to nil. No errors are raised.
|
811
|
-
# @return [Array<Integer, NilClass>] center, width, intercept and slope
|
812
|
-
#
|
813
|
-
def window_level_values
|
814
|
-
center = (self['0028,1050'].is_a?(Element) == true ? self['0028,1050'].value.to_i : nil)
|
815
|
-
width = (self['0028,1051'].is_a?(Element) == true ? self['0028,1051'].value.to_i : nil)
|
816
|
-
intercept = (self['0028,1052'].is_a?(Element) == true ? self['0028,1052'].value.to_i : 0)
|
817
|
-
slope = (self['0028,1053'].is_a?(Element) == true ? self['0028,1053'].value.to_i : 1)
|
818
|
-
return center, width, intercept, slope
|
819
|
-
end
|
820
|
-
|
821
|
-
# Transfers a pre-encoded binary string to the pixel data element, either by
|
822
|
-
# overwriting the existing element value, or creating a new "Pixel Data" element.
|
823
|
-
#
|
824
|
-
# @param [String] bin a binary string containing encoded pixel data
|
825
|
-
#
|
826
|
-
def write_pixels(bin)
|
827
|
-
if self.exists?(PIXEL_TAG)
|
828
|
-
# Update existing Data Element:
|
829
|
-
self[PIXEL_TAG].bin = bin
|
830
|
-
else
|
831
|
-
# Create new Data Element:
|
832
|
-
pixel_element = Element.new(PIXEL_TAG, bin, :encoded => true, :parent => self)
|
833
|
-
end
|
834
|
-
end
|
835
|
-
|
836
|
-
end
|
1
|
+
module DICOM
|
2
|
+
|
3
|
+
# Super class which contains common code for both the DObject and Item classes.
|
4
|
+
# This class includes the image related methods, since images may be stored either
|
5
|
+
# directly in the DObject, or in items (encapsulated items in the "Pixel Data"
|
6
|
+
# element or in "Icon Image Sequence" items).
|
7
|
+
#
|
8
|
+
# === Inheritance
|
9
|
+
#
|
10
|
+
# As the ImageItem class inherits from the Parent class, all Parent methods are
|
11
|
+
# also available to objects which has inherited ImageItem.
|
12
|
+
#
|
13
|
+
class ImageItem < Parent
|
14
|
+
|
15
|
+
include ImageProcessor
|
16
|
+
|
17
|
+
# Creates an Element with the given arguments and connects it to self.
|
18
|
+
#
|
19
|
+
# @param [String] tag an element tag
|
20
|
+
# @param [String, Integer, Float, Array, NilClass] value an element value
|
21
|
+
# @param [Hash] options any options used for creating the element (see Element.new documentation)
|
22
|
+
#
|
23
|
+
def add_element(tag, value, options={})
|
24
|
+
add(e = Element.new(tag, value, options))
|
25
|
+
e
|
26
|
+
end
|
27
|
+
|
28
|
+
# Creates a Sequence with the given arguments and connects it to self.
|
29
|
+
#
|
30
|
+
# @param [String] tag a sequence tag
|
31
|
+
# @param [Hash] options any options used for creating the sequence (see Sequence.new documentation)
|
32
|
+
#
|
33
|
+
def add_sequence(tag, options={})
|
34
|
+
add(s = Sequence.new(tag, options))
|
35
|
+
s
|
36
|
+
end
|
37
|
+
|
38
|
+
# Checks if colored pixel data is present.
|
39
|
+
#
|
40
|
+
# @return [Boolean] true if the object contains colored pixels, and false if not
|
41
|
+
#
|
42
|
+
def color?
|
43
|
+
# "Photometric Interpretation" is contained in the data element "0028,0004":
|
44
|
+
begin
|
45
|
+
photometric = photometry
|
46
|
+
if photometric.include?('COLOR') or photometric.include?('RGB') or photometric.include?('YBR')
|
47
|
+
return true
|
48
|
+
else
|
49
|
+
return false
|
50
|
+
end
|
51
|
+
rescue
|
52
|
+
return false
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# Checks if compressed pixel data is present.
|
57
|
+
#
|
58
|
+
# @return [Boolean] true if the object contains compressed pixels, and false if not
|
59
|
+
#
|
60
|
+
def compression?
|
61
|
+
# If compression is used, the pixel data element is a Sequence (with encapsulated elements), instead of a Element:
|
62
|
+
if self[PIXEL_TAG].is_a?(Sequence)
|
63
|
+
return true
|
64
|
+
else
|
65
|
+
return false
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# Unpacks pixel values from a binary pixel string. The decode is performed
|
70
|
+
# using values defined in the image related elements of the DObject instance.
|
71
|
+
#
|
72
|
+
# @param [String] bin a binary string containing the pixels to be decoded
|
73
|
+
# @param [Stream] stream a Stream instance to be used for decoding the pixels (optional)
|
74
|
+
# @return [Array<Integer>] decoded pixel values
|
75
|
+
#
|
76
|
+
def decode_pixels(bin, stream=@stream)
|
77
|
+
raise ArgumentError, "Expected String, got #{bin.class}." unless bin.is_a?(String)
|
78
|
+
pixels = false
|
79
|
+
# We need to know what kind of bith depth and integer type the pixel data is saved with:
|
80
|
+
bit_depth_element = self['0028,0100']
|
81
|
+
pixel_representation_element = self['0028,0103']
|
82
|
+
if bit_depth_element and pixel_representation_element
|
83
|
+
# Load the binary pixel data to the Stream instance:
|
84
|
+
stream.set_string(bin)
|
85
|
+
template = template_string(bit_depth_element.value.to_i)
|
86
|
+
pixels = stream.decode_all(template) if template
|
87
|
+
else
|
88
|
+
raise "The Element specifying Bit Depth (0028,0100) is missing. Unable to decode pixel data." unless bit_depth_element
|
89
|
+
raise "The Element specifying Pixel Representation (0028,0103) is missing. Unable to decode pixel data." unless pixel_representation_element
|
90
|
+
end
|
91
|
+
return pixels
|
92
|
+
end
|
93
|
+
|
94
|
+
# Packs a pixel value array to a binary pixel string. The encoding is performed
|
95
|
+
# using values defined in the image related elements of the DObject instance.
|
96
|
+
#
|
97
|
+
# @param [Array<Integer>] pixels an array containing the pixel values to be encoded
|
98
|
+
# @param [Stream] stream a Stream instance to be used for encoding the pixels (optional)
|
99
|
+
# @return [String] encoded pixel string
|
100
|
+
#
|
101
|
+
def encode_pixels(pixels, stream=@stream)
|
102
|
+
raise ArgumentError, "Expected Array, got #{pixels.class}." unless pixels.is_a?(Array)
|
103
|
+
bin = false
|
104
|
+
# We need to know what kind of bith depth and integer type the pixel data is saved with:
|
105
|
+
bit_depth_element = self['0028,0100']
|
106
|
+
pixel_representation_element = self['0028,0103']
|
107
|
+
if bit_depth_element and pixel_representation_element
|
108
|
+
template = template_string(bit_depth_element.value.to_i)
|
109
|
+
bin = stream.encode(pixels, template) if template
|
110
|
+
else
|
111
|
+
raise "The Element specifying Bit Depth (0028,0100) is missing. Unable to encode the pixel data." unless bit_depth_element
|
112
|
+
raise "The Element specifying Pixel Representation (0028,0103) is missing. Unable to encode the pixel data." unless pixel_representation_element
|
113
|
+
end
|
114
|
+
return bin
|
115
|
+
end
|
116
|
+
|
117
|
+
# Extracts a single image object, created from the encoded pixel data using
|
118
|
+
# the image related elements in the DICOM object. If the object contains multiple
|
119
|
+
# image frames, the first image frame is returned, unless the :frame option is used.
|
120
|
+
#
|
121
|
+
# @note Creates an image object in accordance with the selected image processor. Available processors are :rmagick and :mini_magick.
|
122
|
+
#
|
123
|
+
# @param [Hash] options the options to use for extracting the image
|
124
|
+
# @option options [Integer] :frame for DICOM objects containing multiple frames, this option can be used to extract a specific image frame (defaults to 0)
|
125
|
+
# @option options [TrueClass, Array<Integer>] :level if true, window leveling is performed using default values from the DICOM object, or if an array ([center, width]) is specified, these custom values are used instead
|
126
|
+
# @option options [Boolean] :narray if true, forces the use of NArray for the pixel remap process (for faster execution)
|
127
|
+
# @option options [Boolean] :remap if true, the returned pixel values are remapped to presentation values
|
128
|
+
# @return [MagickImage, NilClass, FalseClass] an image object, alternatively nil (if no image present) or false (if image decode failed)
|
129
|
+
#
|
130
|
+
# @example Retrieve pixel data as an RMagick image object and display it
|
131
|
+
# image = dcm.image
|
132
|
+
# image.display
|
133
|
+
# @example Retrieve frame index 5 in the pixel data
|
134
|
+
# image = dcm.image(:frame => 5)
|
135
|
+
#
|
136
|
+
def image(options={})
|
137
|
+
options[:frame] = options[:frame] || 0
|
138
|
+
image = images(options).first
|
139
|
+
image = false if image.nil? && exists?(PIXEL_TAG)
|
140
|
+
return image
|
141
|
+
end
|
142
|
+
|
143
|
+
# Extracts an array of image objects, created from the encoded pixel data using
|
144
|
+
# the image related elements in the DICOM object.
|
145
|
+
#
|
146
|
+
# @note Creates an array of image objects in accordance with the selected image processor. Available processors are :rmagick and :mini_magick.
|
147
|
+
#
|
148
|
+
# @param [Hash] options the options to use for extracting the images
|
149
|
+
# @option options [Integer] :frame makes the method return an array containing only the image object corresponding to the specified frame number
|
150
|
+
# @option options [TrueClass, Array<Integer>] :level if true, window leveling is performed using default values from the DICOM object, or if an array ([center, width]) is specified, these custom values are used instead
|
151
|
+
# @option options [Boolean] :narray if true, forces the use of NArray for the pixel remap process (for faster execution)
|
152
|
+
# @option options [Boolean] :remap if true, the returned pixel values are remapped to presentation values
|
153
|
+
# @return [Array<MagickImage, NilClass>] an array of image objects, alternatively an empty array (if no image present or image decode failed)
|
154
|
+
#
|
155
|
+
# @example Retrieve the pixel data as RMagick image objects
|
156
|
+
# images = dcm.images
|
157
|
+
# @example Retrieve the pixel data as RMagick image objects, remapped to presentation values (but without any leveling)
|
158
|
+
# images = dcm.images(:remap => true)
|
159
|
+
# @example Retrieve the pixel data as RMagick image objects, remapped to presentation values and leveled using the default center/width values in the DICOM object
|
160
|
+
# images = dcm.images(:level => true)
|
161
|
+
# @example Retrieve the pixel data as RMagick image objects, remapped to presentation values, leveled with the specified center/width values and using numerical array for the rescaling (~twice as fast)
|
162
|
+
# images = dcm.images(:level => [-200,1000], :narray => true)
|
163
|
+
#
|
164
|
+
def images(options={})
|
165
|
+
images = Array.new
|
166
|
+
if exists?(PIXEL_TAG)
|
167
|
+
# Gather the pixel data strings, and pick a single frame if indicated by options:
|
168
|
+
strings = image_strings(split_to_frames=true)
|
169
|
+
strings = [strings[options[:frame]]] if options[:frame]
|
170
|
+
if compression?
|
171
|
+
# Decompress, either to numbers (RLE) or to an image object (image based compressions):
|
172
|
+
if [TXS_RLE].include?(transfer_syntax)
|
173
|
+
pixel_frames = Array.new
|
174
|
+
strings.each {|string| pixel_frames << decode_rle(num_cols, num_rows, string)}
|
175
|
+
else
|
176
|
+
images = decompress(strings) || Array.new
|
177
|
+
logger.warn("Decompressing pixel values has failed (unsupported transfer syntax: '#{transfer_syntax}' - #{LIBRARY.uid(transfer_syntax) ? LIBRARY.uid(transfer_syntax).name : 'Unknown transfer syntax!'})") unless images.length > 0
|
178
|
+
end
|
179
|
+
else
|
180
|
+
# Uncompressed: Decode to numbers.
|
181
|
+
pixel_frames = Array.new
|
182
|
+
strings.each {|string| pixel_frames << decode_pixels(string)}
|
183
|
+
end
|
184
|
+
if pixel_frames
|
185
|
+
images = Array.new
|
186
|
+
pixel_frames.each do |pixels|
|
187
|
+
# Pixel values and pixel order may need to be rearranged if we have color data:
|
188
|
+
pixels = process_colors(pixels) if color?
|
189
|
+
if pixels
|
190
|
+
images << read_image(pixels, num_cols, num_rows, options)
|
191
|
+
else
|
192
|
+
logger.warn("Processing pixel values for this particular color mode failed, unable to construct image(s).")
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
197
|
+
return images
|
198
|
+
end
|
199
|
+
|
200
|
+
# Reads a binary string from a specified file and writes it to the value field of the pixel data element (7FE0,0010).
|
201
|
+
#
|
202
|
+
# @param [String] file a string which specifies the path of the file containing pixel data
|
203
|
+
#
|
204
|
+
# @example Load pixel data from a file
|
205
|
+
# dcm.image_from_file("custom_image.dat")
|
206
|
+
#
|
207
|
+
def image_from_file(file)
|
208
|
+
raise ArgumentError, "Expected #{String}, got #{file.class}." unless file.is_a?(String)
|
209
|
+
f = File.new(file, 'rb')
|
210
|
+
bin = f.read(f.stat.size)
|
211
|
+
if bin.length > 0
|
212
|
+
# Write the binary data to the Pixel Data Element:
|
213
|
+
write_pixels(bin)
|
214
|
+
else
|
215
|
+
logger.info("The specified file (#{file}) is empty. Nothing to transfer.")
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
# Extracts the pixel data binary string(s) in an array.
|
220
|
+
#
|
221
|
+
# @param [Boolean] split if true, a pixel data string containing 3D volumetric data will be split into N substrings (where N equals the number of frames)
|
222
|
+
# @return [Array<String, NilClass>] an array of pixel data strings, or an empty array (if no pixel data present)
|
223
|
+
#
|
224
|
+
def image_strings(split=false)
|
225
|
+
# Pixel data may be a single binary string in the pixel data element,
|
226
|
+
# or located in several encapsulated item elements:
|
227
|
+
pixel_element = self[PIXEL_TAG]
|
228
|
+
strings = Array.new
|
229
|
+
if pixel_element.is_a?(Element)
|
230
|
+
if split
|
231
|
+
strings = pixel_element.bin.dup.divide(num_frames)
|
232
|
+
else
|
233
|
+
strings << pixel_element.bin
|
234
|
+
end
|
235
|
+
elsif pixel_element.is_a?(Sequence)
|
236
|
+
pixel_items = pixel_element.children.first.children
|
237
|
+
pixel_items.each {|item| strings << item.bin}
|
238
|
+
end
|
239
|
+
return strings
|
240
|
+
end
|
241
|
+
|
242
|
+
# Dumps the binary content of the Pixel Data element to the specified file.
|
243
|
+
#
|
244
|
+
# If the DICOM object contains multi-fragment pixel data, each fragment
|
245
|
+
# will be dumped to separate files (e.q. 'fragment-0.dat', 'fragment-1.dat').
|
246
|
+
#
|
247
|
+
# @param [String] file a string which specifies the file path to use when dumping the pixel data
|
248
|
+
# @example Dumping the pixel data to a file
|
249
|
+
# dcm.image_to_file("exported_image.dat")
|
250
|
+
#
|
251
|
+
def image_to_file(file)
|
252
|
+
raise ArgumentError, "Expected #{String}, got #{file.class}." unless file.is_a?(String)
|
253
|
+
# Split the file name in case of multiple fragments:
|
254
|
+
parts = file.split('.')
|
255
|
+
if parts.length > 1
|
256
|
+
base = parts[0..-2].join
|
257
|
+
extension = '.' + parts.last
|
258
|
+
else
|
259
|
+
base = file
|
260
|
+
extension = ''
|
261
|
+
end
|
262
|
+
# Get the binary image strings and dump them to the file(s):
|
263
|
+
images = image_strings
|
264
|
+
images.each_index do |i|
|
265
|
+
if images.length == 1
|
266
|
+
f = File.new(file, 'wb')
|
267
|
+
else
|
268
|
+
f = File.new("#{base}-#{i}#{extension}", 'wb')
|
269
|
+
end
|
270
|
+
f.write(images[i])
|
271
|
+
f.close
|
272
|
+
end
|
273
|
+
end
|
274
|
+
|
275
|
+
# Encodes pixel data from a (Magick) image object and writes it to the
|
276
|
+
# pixel data element (7FE0,0010).
|
277
|
+
#
|
278
|
+
# Because of pixel value issues related to image objects (images don't like
|
279
|
+
# signed integers), and the possible difference between presentation values
|
280
|
+
# and raw pixel values, the use of image=() may result in pixel data where the
|
281
|
+
# integer values differs somewhat from what is expected. Use with care! For
|
282
|
+
# precise pixel value processing, use the Array and NArray based pixel data methods instead.
|
283
|
+
#
|
284
|
+
# @param [MagickImage] image the image to be assigned to the pixel data element
|
285
|
+
#
|
286
|
+
def image=(image)
|
287
|
+
raise ArgumentError, "Expected one of the supported image classes: #{valid_image_objects} (got #{image.class})" unless valid_image_objects.include?(image.class.to_s)
|
288
|
+
# Export to pixels using the proper image processor:
|
289
|
+
pixels = export_pixels(image, photometry)
|
290
|
+
# Encode and write to the Pixel Data Element:
|
291
|
+
self.pixels = pixels
|
292
|
+
end
|
293
|
+
|
294
|
+
# Gives the number of columns in the pixel data.
|
295
|
+
#
|
296
|
+
# @return [Integer, NilClass] the number of columns, or nil (if the columns value is undefined)
|
297
|
+
#
|
298
|
+
def num_cols
|
299
|
+
self['0028,0011'].value rescue nil
|
300
|
+
end
|
301
|
+
|
302
|
+
# Gives the number of frames in the pixel data.
|
303
|
+
#
|
304
|
+
# @note Assumes and gives 1 if the number of frames value is not defined.
|
305
|
+
# @return [Integer] the number of rows
|
306
|
+
#
|
307
|
+
def num_frames
|
308
|
+
(self['0028,0008'].is_a?(Element) == true ? self['0028,0008'].value.to_i : 1)
|
309
|
+
end
|
310
|
+
|
311
|
+
# Gives the number of rows in the pixel data.
|
312
|
+
#
|
313
|
+
# @return [Integer, NilClass] the number of rows, or nil (if the rows value is undefined)
|
314
|
+
#
|
315
|
+
def num_rows
|
316
|
+
self['0028,0010'].value rescue nil
|
317
|
+
end
|
318
|
+
|
319
|
+
# Creates an NArray containing the pixel data. If the pixel data is an image
|
320
|
+
# (single frame), a 2-dimensional NArray is returned [columns, rows]. If the
|
321
|
+
# pixel data is 3-dimensional (more than one frame), a 3-dimensional NArray
|
322
|
+
# is returned [frames, columns, rows].
|
323
|
+
#
|
324
|
+
# @note To call this method you need to have loaded the NArray library in advance (require 'narray').
|
325
|
+
#
|
326
|
+
# @param [Hash] options the options to use for extracting the pixel data
|
327
|
+
# @option options [TrueClass, Array<Integer>] :level if true, window leveling is performed using default values from the DICOM object, or if an array ([center, width]) is specified, these custom values are used instead
|
328
|
+
# @option options [Boolean] :remap if true, the returned pixel values are remapped to presentation values
|
329
|
+
# @option options [Boolean] :volume if true, the returned array will always be 3-dimensional, even if the pixel data only has one frame
|
330
|
+
# @return [NArray, NilClass, FalseClass] an NArray of pixel values, alternatively nil (if no image present) or false (if image decode failed)
|
331
|
+
#
|
332
|
+
# @example Retrieve numerical pixel array
|
333
|
+
# data = dcm.narray
|
334
|
+
# @example Retrieve numerical pixel array remapped from the original pixel values to presentation values
|
335
|
+
# data = dcm.narray(:remap => true)
|
336
|
+
#
|
337
|
+
def narray(options={})
|
338
|
+
pixels = nil
|
339
|
+
if exists?(PIXEL_TAG)
|
340
|
+
unless color?
|
341
|
+
# Decode the pixel values: For now we only support returning pixel data of the first frame (if the image is located in multiple pixel data items).
|
342
|
+
if compression?
|
343
|
+
pixels = decompress(image_strings.first)
|
344
|
+
else
|
345
|
+
pixels = decode_pixels(image_strings.first)
|
346
|
+
end
|
347
|
+
if pixels
|
348
|
+
# Import the pixels to NArray and give it a proper shape:
|
349
|
+
raise "Missing Rows and/or Columns Element. Unable to construct pixel data array." unless num_rows and num_cols
|
350
|
+
if num_frames > 1 or options[:volume]
|
351
|
+
# Create an empty 3D NArray. fill it with pixels frame by frame, then reassign the pixels variable to it:
|
352
|
+
narr = NArray.int(num_frames, num_cols, num_rows)
|
353
|
+
num_frames.times do |i|
|
354
|
+
narr[i, true, true] = NArray.to_na(pixels[(i * num_cols * num_rows)..((i + 1) * num_cols * num_rows - 1)]).reshape!(num_cols, num_rows)
|
355
|
+
end
|
356
|
+
pixels = narr
|
357
|
+
else
|
358
|
+
pixels = NArray.to_na(pixels).reshape!(num_cols, num_rows)
|
359
|
+
end
|
360
|
+
# Remap the image from pixel values to presentation values if the user has requested this:
|
361
|
+
pixels = process_presentation_values_narray(pixels, -65535, 65535, options[:level]) if options[:remap] or options[:level]
|
362
|
+
else
|
363
|
+
logger.warn("Decompressing the Pixel Data failed. Pixel values can not be extracted.")
|
364
|
+
end
|
365
|
+
else
|
366
|
+
logger.warn("The DICOM object contains colored pixel data. Retrieval of colored pixels is not supported by this method yet.")
|
367
|
+
pixels = false
|
368
|
+
end
|
369
|
+
end
|
370
|
+
return pixels
|
371
|
+
end
|
372
|
+
|
373
|
+
# Extracts the Pixel Data values in an ordinary Ruby Array.
|
374
|
+
# Returns nil if no pixel data is present, and false if it fails to retrieve pixel data which is present.
|
375
|
+
#
|
376
|
+
# The returned array does not carry the dimensions of the pixel data:
|
377
|
+
# It is put in a one dimensional Array (vector).
|
378
|
+
#
|
379
|
+
# @param [Hash] options the options to use for extracting the pixel data
|
380
|
+
# @option options [TrueClass, Array<Integer>] :level if true, window leveling is performed using default values from the DICOM object, or if an array ([center, width]) is specified, these custom values are used instead
|
381
|
+
# @option options [Boolean] :narray if true, forces the use of NArray for the pixel remap process (for faster execution)
|
382
|
+
# @option options [Boolean] :remap if true, the returned pixel values are remapped to presentation values
|
383
|
+
# @return [Array, NilClass, FalseClass] an Array of pixel values, alternatively nil (if no image present) or false (if image decode failed)
|
384
|
+
#
|
385
|
+
# @example Simply retrieve the pixel data
|
386
|
+
# pixels = dcm.pixels
|
387
|
+
# @example Retrieve the pixel data remapped to presentation values according to window center/width settings
|
388
|
+
# pixels = dcm.pixels(:remap => true)
|
389
|
+
# @example Retrieve the remapped pixel data while using numerical array (~twice as fast)
|
390
|
+
# pixels = dcm.pixels(:remap => true, :narray => true)
|
391
|
+
#
|
392
|
+
def pixels(options={})
|
393
|
+
pixels = nil
|
394
|
+
if exists?(PIXEL_TAG)
|
395
|
+
# For now we only support returning pixel data of the first frame, if the image is located in multiple pixel data items:
|
396
|
+
if compression?
|
397
|
+
pixels = decompress(image_strings.first)
|
398
|
+
else
|
399
|
+
pixels = decode_pixels(image_strings.first)
|
400
|
+
end
|
401
|
+
if pixels
|
402
|
+
# Remap the image from pixel values to presentation values if the user has requested this:
|
403
|
+
if options[:remap] or options[:level]
|
404
|
+
if options[:narray]
|
405
|
+
# Use numerical array (faster):
|
406
|
+
pixels = process_presentation_values_narray(pixels, -65535, 65535, options[:level]).to_a
|
407
|
+
else
|
408
|
+
# Use standard Ruby array (slower):
|
409
|
+
pixels = process_presentation_values(pixels, -65535, 65535, options[:level])
|
410
|
+
end
|
411
|
+
end
|
412
|
+
else
|
413
|
+
logger.warn("Decompressing the Pixel Data failed. Pixel values can not be extracted.")
|
414
|
+
end
|
415
|
+
end
|
416
|
+
return pixels
|
417
|
+
end
|
418
|
+
|
419
|
+
# Encodes pixel data from a Ruby Array or NArray, and writes it to the pixel data element (7FE0,0010).
|
420
|
+
#
|
421
|
+
# @param [Array<Integer>, NArray] values an Array (or NArray) containing integer pixel values
|
422
|
+
#
|
423
|
+
def pixels=(values)
|
424
|
+
raise ArgumentError, "The given argument does not respond to #to_a (got an argument of class #{values.class})" unless values.respond_to?(:to_a)
|
425
|
+
if values.class.ancestors.to_s.include?('NArray')
|
426
|
+
# With an NArray argument, make sure that it gets properly converted to an Array:
|
427
|
+
if values.shape.length > 2
|
428
|
+
# For a 3D NArray we need to rearrange to ensure that the pixels get their
|
429
|
+
# proper order when converting to an ordinary Array instance:
|
430
|
+
narr = NArray.int(values.shape[1] * values.shape[2], values.shape[0])
|
431
|
+
values.shape[0].times do |i|
|
432
|
+
narr[true, i] = values[i, true, true].reshape(values.shape[1] * values.shape[2])
|
433
|
+
end
|
434
|
+
values = narr
|
435
|
+
end
|
436
|
+
end
|
437
|
+
# Encode the pixel data:
|
438
|
+
bin = encode_pixels(values.to_a.flatten)
|
439
|
+
# Write the binary data to the Pixel Data Element:
|
440
|
+
write_pixels(bin)
|
441
|
+
end
|
442
|
+
|
443
|
+
# Delete all Sequence instances from the DObject or Item instance.
|
444
|
+
#
|
445
|
+
def delete_sequences
|
446
|
+
@tags.each_value do |element|
|
447
|
+
delete(element.tag) if element.is_a?(Sequence)
|
448
|
+
end
|
449
|
+
end
|
450
|
+
|
451
|
+
|
452
|
+
private
|
453
|
+
|
454
|
+
|
455
|
+
# Gives the effective bit depth of the pixel data (considers a special case
|
456
|
+
# for Palette colored images).
|
457
|
+
#
|
458
|
+
# @raise [RuntimeError] if the 'Bits Allocated' element is missing
|
459
|
+
# @return [Integer] the effective bit depth of the pixel data
|
460
|
+
#
|
461
|
+
def actual_bit_depth
|
462
|
+
raise "The 'Bits Allocated' Element is missing from this DICOM instance. Unable to encode/decode pixel data." unless exists?("0028,0100")
|
463
|
+
if photometry == PI_PALETTE_COLOR
|
464
|
+
# Only one channel is checked and it is assumed that all channels have the same number of bits.
|
465
|
+
return self['0028,1101'].value.split("\\").last.to_i
|
466
|
+
else
|
467
|
+
return bit_depth
|
468
|
+
end
|
469
|
+
end
|
470
|
+
|
471
|
+
|
472
|
+
# Gives the value from the "Bits Allocated" Element.
|
473
|
+
#
|
474
|
+
# @raise [RuntimeError] if the 'Bits Allocated' element is missing
|
475
|
+
# @return [Integer] the number of bits allocated
|
476
|
+
#
|
477
|
+
def bit_depth
|
478
|
+
raise "The 'Bits Allocated' Element is missing from this DICOM instance. Unable to encode/decode pixel data." unless exists?("0028,0100")
|
479
|
+
return value('0028,0100')
|
480
|
+
end
|
481
|
+
|
482
|
+
# Performs a run length decoding on the input stream.
|
483
|
+
#
|
484
|
+
# @note For details on RLE encoding, refer to the DICOM standard, PS3.5, Section 8.2.2 as well as Annex G.
|
485
|
+
#
|
486
|
+
# @param [Integer] cols number of colums of the encoded image
|
487
|
+
# @param [Integer] rows number of rows of the encoded image
|
488
|
+
# @param [Integer] string the encoded pixel string
|
489
|
+
# @return [Array<Integer>] the decoded pixel values
|
490
|
+
#
|
491
|
+
def decode_rle(cols, rows, string)
|
492
|
+
# FIXME: Remove cols and rows (were only added for debugging).
|
493
|
+
pixels = Array.new
|
494
|
+
# RLE header specifying the number of segments:
|
495
|
+
header = string[0...64].unpack('L*')
|
496
|
+
image_segments = Array.new
|
497
|
+
# Extracting all start and endpoints of the different segments:
|
498
|
+
header.each_index do |n|
|
499
|
+
if n == 0
|
500
|
+
# This one need no processing.
|
501
|
+
elsif n == header[0]
|
502
|
+
# It's the last one
|
503
|
+
image_segments << [header[n], -1]
|
504
|
+
break
|
505
|
+
else
|
506
|
+
image_segments << [header[n], header[n + 1] - 1]
|
507
|
+
end
|
508
|
+
end
|
509
|
+
# Iterate over each segment and extract pixel data:
|
510
|
+
image_segments.each do |range|
|
511
|
+
segment_data = Array.new
|
512
|
+
next_bytes = -1
|
513
|
+
next_multiplier = 0
|
514
|
+
# Iterate this segment's pixel string:
|
515
|
+
string[range[0]..range[1]].each_byte do |b|
|
516
|
+
if next_multiplier > 0
|
517
|
+
next_multiplier.times { segment_data << b }
|
518
|
+
next_multiplier = 0
|
519
|
+
elsif next_bytes > 0
|
520
|
+
segment_data << b
|
521
|
+
next_bytes -= 1
|
522
|
+
elsif b <= 127
|
523
|
+
next_bytes = b + 1
|
524
|
+
else
|
525
|
+
# Explaining the 257 at this point is a little bit complicate. Basically it has something
|
526
|
+
# to do with the algorithm described in the DICOM standard and that the value -1 as uint8 is 255.
|
527
|
+
# TODO: Is this architectur safe or does it only work on Intel systems???
|
528
|
+
next_multiplier = 257 - b
|
529
|
+
end
|
530
|
+
end
|
531
|
+
# Verify that the RLE decoding has executed properly:
|
532
|
+
throw "Size mismatch #{segment_data.size} != #{rows * cols}" if segment_data.size != rows * cols
|
533
|
+
pixels += segment_data
|
534
|
+
end
|
535
|
+
return pixels
|
536
|
+
end
|
537
|
+
|
538
|
+
# Gives the value from the "Photometric Interpretation" Element.
|
539
|
+
#
|
540
|
+
# @raise [RuntimeError] if the 'Photometric Interpretation' element is missing
|
541
|
+
# @return [String] the photometric interpretation
|
542
|
+
#
|
543
|
+
def photometry
|
544
|
+
raise "The 'Photometric Interpretation' Element is missing from this DICOM instance. Unable to encode/decode pixel data." unless exists?("0028,0004")
|
545
|
+
return value('0028,0004').upcase
|
546
|
+
end
|
547
|
+
|
548
|
+
# Processes the pixel array based on attributes defined in the DICOM object,
|
549
|
+
#to produce a pixel array with correct pixel colors (RGB) as well as pixel
|
550
|
+
# order (RGB-pixel1, RGB-pixel2, etc). The relevant DICOM tags are
|
551
|
+
# Photometric Interpretation (0028,0004) and Planar Configuration (0028,0006).
|
552
|
+
#
|
553
|
+
# @param [Array<Integer>] pixels an array of (unsorted) color pixel values
|
554
|
+
# @return [Array<Integer>] an array of properly (sorted) color pixel values
|
555
|
+
#
|
556
|
+
def process_colors(pixels)
|
557
|
+
proper_rgb = false
|
558
|
+
photometric = photometry()
|
559
|
+
# (With RLE COLOR PALETTE the Planar Configuration is not set)
|
560
|
+
planar = self['0028,0006'].is_a?(Element) ? self['0028,0006'].value : 0
|
561
|
+
# Step 1: Produce an array with RGB values. At this time, YBR is not supported in ruby-dicom,
|
562
|
+
# so this leaves us with a possible conversion from PALETTE COLOR:
|
563
|
+
if photometric.include?('COLOR')
|
564
|
+
# Pseudo colors (rgb values grabbed from a lookup table):
|
565
|
+
rgb = Array.new(pixels.length*3)
|
566
|
+
# Prepare the lookup data arrays:
|
567
|
+
lookup_binaries = [self['0028,1201'].bin, self['0028,1202'].bin, self['0028,1203'].bin]
|
568
|
+
lookup_values = Array.new
|
569
|
+
nr_bits = self['0028,1101'].value.split("\\").last.to_i
|
570
|
+
template = template_string(nr_bits)
|
571
|
+
lookup_binaries.each do |bin|
|
572
|
+
stream.set_string(bin)
|
573
|
+
lookup_values << stream.decode_all(template)
|
574
|
+
end
|
575
|
+
lookup_values = lookup_values.transpose
|
576
|
+
# Fill the RGB array, one RGB pixel group (3 pixels) at a time:
|
577
|
+
pixels.each_index do |i|
|
578
|
+
rgb[i*3, 3] = lookup_values[pixels[i]]
|
579
|
+
end
|
580
|
+
# As we have now ordered the pixels in RGB order, modify planar configuration to reflect this:
|
581
|
+
planar = 0
|
582
|
+
elsif photometric.include?('YBR')
|
583
|
+
rgb = false
|
584
|
+
else
|
585
|
+
rgb = pixels
|
586
|
+
end
|
587
|
+
# Step 2: If indicated by the planar configuration, the order of the pixels need to be rearranged:
|
588
|
+
if rgb
|
589
|
+
if planar == 1
|
590
|
+
# Rearrange from [RRR...GGG....BBB...] to [(RGB)(RGB)(RGB)...]:
|
591
|
+
r_ind = [rgb.length/3-1, rgb.length*2/3-1, rgb.length-1]
|
592
|
+
l_ind = [0, rgb.length/3, rgb.length*2/3]
|
593
|
+
proper_rgb = [rgb[l_ind[0]..r_ind[0]], rgb[l_ind[1]..r_ind[1]], rgb[l_ind[2]..r_ind[2]]].transpose.flatten
|
594
|
+
else
|
595
|
+
proper_rgb = rgb
|
596
|
+
end
|
597
|
+
end
|
598
|
+
return proper_rgb
|
599
|
+
end
|
600
|
+
|
601
|
+
# Converts original pixel data values to presentation values.
|
602
|
+
#
|
603
|
+
# @param [Array<Integer>] pixel_data an array of pixel values (integers)
|
604
|
+
# @param [Integer] min_allowed the minimum value allowed in the pixel data
|
605
|
+
# @param [Integer] max_allowed the maximum value allowed in the pixel data
|
606
|
+
# @param [Boolean, Array<Integer>] level if true, window leveling is performed using default values from the DICOM object, or if an array ([center, width]) is specified, these custom values are used instead
|
607
|
+
# @return [Array<Integer>] presentation values
|
608
|
+
#
|
609
|
+
def process_presentation_values(pixel_data, min_allowed, max_allowed, level=nil)
|
610
|
+
# Process pixel data for presentation according to the image information in the DICOM object:
|
611
|
+
center, width, intercept, slope = window_level_values
|
612
|
+
# Have image leveling been requested?
|
613
|
+
if level
|
614
|
+
# If custom values are specified in an array, use those. If not, the default values from the DICOM object are used:
|
615
|
+
if level.is_a?(Array)
|
616
|
+
center = level[0]
|
617
|
+
width = level[1]
|
618
|
+
end
|
619
|
+
else
|
620
|
+
center, width = false, false
|
621
|
+
end
|
622
|
+
# PixelOutput = slope * pixel_values + intercept
|
623
|
+
if intercept != 0 or slope != 1
|
624
|
+
pixel_data.collect!{|x| (slope * x) + intercept}
|
625
|
+
end
|
626
|
+
# Contrast enhancement by black and white thresholding:
|
627
|
+
if center and width
|
628
|
+
low = center - width/2
|
629
|
+
high = center + width/2
|
630
|
+
pixel_data.each_index do |i|
|
631
|
+
if pixel_data[i] < low
|
632
|
+
pixel_data[i] = low
|
633
|
+
elsif pixel_data[i] > high
|
634
|
+
pixel_data[i] = high
|
635
|
+
end
|
636
|
+
end
|
637
|
+
end
|
638
|
+
# Need to introduce an offset?
|
639
|
+
min_pixel_value = pixel_data.min
|
640
|
+
if min_allowed
|
641
|
+
if min_pixel_value < min_allowed
|
642
|
+
offset = min_pixel_value.abs
|
643
|
+
pixel_data.collect!{|x| x + offset}
|
644
|
+
end
|
645
|
+
end
|
646
|
+
# Downscale pixel range?
|
647
|
+
max_pixel_value = pixel_data.max
|
648
|
+
if max_allowed
|
649
|
+
if max_pixel_value > max_allowed
|
650
|
+
factor = (max_pixel_value.to_f/max_allowed.to_f).ceil
|
651
|
+
pixel_data.collect!{|x| x / factor}
|
652
|
+
end
|
653
|
+
end
|
654
|
+
return pixel_data
|
655
|
+
end
|
656
|
+
|
657
|
+
# Converts original pixel data values to presentation values, using the efficient NArray library.
|
658
|
+
#
|
659
|
+
# @note If a Ruby Array is supplied, the method returns a one-dimensional NArray object (i.e. no columns & rows).
|
660
|
+
# @note If a NArray is supplied, the NArray is returned with its original dimensions.
|
661
|
+
#
|
662
|
+
# @param [Array<Integer>, NArray] pixel_data pixel values
|
663
|
+
# @param [Integer] min_allowed the minimum value allowed in the pixel data
|
664
|
+
# @param [Integer] max_allowed the maximum value allowed in the pixel data
|
665
|
+
# @param [Boolean, Array<Integer>] level if true, window leveling is performed using default values from the DICOM object, or if an array ([center, width]) is specified, these custom values are used instead
|
666
|
+
# @return [Array<Integer>, NArray] presentation values
|
667
|
+
#
|
668
|
+
def process_presentation_values_narray(pixel_data, min_allowed, max_allowed, level=nil)
|
669
|
+
# Process pixel data for presentation according to the image information in the DICOM object:
|
670
|
+
center, width, intercept, slope = window_level_values
|
671
|
+
# Have image leveling been requested?
|
672
|
+
if level
|
673
|
+
# If custom values are specified in an array, use those. If not, the default values from the DICOM object are used:
|
674
|
+
if level.is_a?(Array)
|
675
|
+
center = level[0]
|
676
|
+
width = level[1]
|
677
|
+
end
|
678
|
+
else
|
679
|
+
center, width = false, false
|
680
|
+
end
|
681
|
+
# Need to convert to NArray?
|
682
|
+
if pixel_data.is_a?(Array)
|
683
|
+
n_arr = NArray.to_na(pixel_data)
|
684
|
+
else
|
685
|
+
n_arr = pixel_data
|
686
|
+
end
|
687
|
+
# Remap:
|
688
|
+
# PixelOutput = slope * pixel_values + intercept
|
689
|
+
if intercept != 0 or slope != 1
|
690
|
+
n_arr = slope * n_arr + intercept
|
691
|
+
end
|
692
|
+
# Contrast enhancement by black and white thresholding:
|
693
|
+
if center and width
|
694
|
+
low = center - width/2
|
695
|
+
high = center + width/2
|
696
|
+
n_arr[n_arr < low] = low
|
697
|
+
n_arr[n_arr > high] = high
|
698
|
+
end
|
699
|
+
# Need to introduce an offset?
|
700
|
+
min_pixel_value = n_arr.min
|
701
|
+
if min_allowed
|
702
|
+
if min_pixel_value < min_allowed
|
703
|
+
offset = min_pixel_value.abs
|
704
|
+
n_arr = n_arr + offset
|
705
|
+
end
|
706
|
+
end
|
707
|
+
# Downscale pixel range?
|
708
|
+
max_pixel_value = n_arr.max
|
709
|
+
if max_allowed
|
710
|
+
if max_pixel_value > max_allowed
|
711
|
+
factor = (max_pixel_value.to_f/max_allowed.to_f).ceil
|
712
|
+
n_arr = n_arr / factor
|
713
|
+
end
|
714
|
+
end
|
715
|
+
return n_arr
|
716
|
+
end
|
717
|
+
|
718
|
+
# Creates an image object from the specified pixel value array, performing
|
719
|
+
# presentation value processing if requested.
|
720
|
+
#
|
721
|
+
# @note Definitions for Window Center and Width can be found in the DICOM standard, PS 3.3 C.11.2.1.2
|
722
|
+
#
|
723
|
+
# @param [Array<Integer>] pixel_data an array of pixel values
|
724
|
+
# @param [Integer] columns the number of columns in the pixel data
|
725
|
+
# @param [Integer] rows the number of rows in the pixel data
|
726
|
+
# @param [Hash] options the options to use for reading the image
|
727
|
+
# @option options [Boolean] :remap if true, pixel values are remapped to presentation values (using intercept and slope values from the DICOM object)
|
728
|
+
# @option options [Boolean, Array<Integer>] :level if true, window leveling is performed using default values from the DICOM object, or if an array ([center, width]) is specified, these custom values are used instead
|
729
|
+
# @option options [Boolean] :narray if true, forces the use of NArray for the pixel remap process (for faster execution)
|
730
|
+
# @return [MagickImage] the extracted image object
|
731
|
+
#
|
732
|
+
def read_image(pixel_data, columns, rows, options={})
|
733
|
+
raise ArgumentError, "Expected Array for pixel_data, got #{pixel_data.class}" unless pixel_data.is_a?(Array)
|
734
|
+
raise ArgumentError, "Expected Integer for columns, got #{columns.class}" unless columns.is_a?(Integer)
|
735
|
+
raise ArgumentError, "Expected Rows for columns, got #{rows.class}" unless rows.is_a?(Integer)
|
736
|
+
raise ArgumentError, "Size of pixel_data must be at least equal to columns*rows. Got #{columns}*#{rows}=#{columns*rows}, which is less than the array size #{pixel_data.length}" if columns * rows > pixel_data.length
|
737
|
+
# Remap the image from pixel values to presentation values if the user has requested this:
|
738
|
+
if options[:remap] or options[:level]
|
739
|
+
# How to perform the remapping? NArray (fast) or Ruby Array (slow)?
|
740
|
+
if options[:narray] == true
|
741
|
+
pixel_data = process_presentation_values_narray(pixel_data, 0, 65535, options[:level]).to_a
|
742
|
+
else
|
743
|
+
pixel_data = process_presentation_values(pixel_data, 0, 65535, options[:level])
|
744
|
+
end
|
745
|
+
else
|
746
|
+
# No remapping, but make sure that we pass on unsigned pixel values to the image processor:
|
747
|
+
pixel_data = pixel_data.to_unsigned(bit_depth) if signed_pixels?
|
748
|
+
end
|
749
|
+
image = import_pixels(pixel_data.to_blob(actual_bit_depth), columns, rows, actual_bit_depth, photometry)
|
750
|
+
return image
|
751
|
+
end
|
752
|
+
|
753
|
+
# Checks if the Pixel Representation indicates signed pixel values or not.
|
754
|
+
#
|
755
|
+
# @raise [RuntimeError] if the 'Pixel Representation' element is missing
|
756
|
+
# @return [Boolean] true if pixel values are signed, false if not
|
757
|
+
#
|
758
|
+
def signed_pixels?
|
759
|
+
raise "The 'Pixel Representation' data element is missing from this DICOM instance. Unable to process pixel data." unless exists?("0028,0103")
|
760
|
+
case value('0028,0103')
|
761
|
+
when 1
|
762
|
+
return true
|
763
|
+
when 0
|
764
|
+
return false
|
765
|
+
else
|
766
|
+
raise "Invalid value encountered (#{value('0028,0103')}) in the 'Pixel Representation' data element. Expected 0 or 1."
|
767
|
+
end
|
768
|
+
end
|
769
|
+
|
770
|
+
# Determines the template/format string for pack/unpacking pixel data, based on
|
771
|
+
# the number of bits per pixel as well as the pixel representation (signed or unsigned).
|
772
|
+
#
|
773
|
+
# @param [Integer] depth the number of allocated bits in the integers to be decoded/encoded
|
774
|
+
# @return [String] a format string
|
775
|
+
#
|
776
|
+
def template_string(depth)
|
777
|
+
template = false
|
778
|
+
pixel_representation = self['0028,0103'].value.to_i
|
779
|
+
# Number of bytes used per pixel will determine how to unpack this:
|
780
|
+
case depth
|
781
|
+
when 8 # (1 byte)
|
782
|
+
template = 'BY' # Byte/Character/Fixnum
|
783
|
+
when 16 # (2 bytes)
|
784
|
+
if pixel_representation == 1
|
785
|
+
template = 'SS' # Signed short
|
786
|
+
else
|
787
|
+
template = 'US' # Unsigned short
|
788
|
+
end
|
789
|
+
when 32 # (4 bytes)
|
790
|
+
if pixel_representation == 1
|
791
|
+
template = 'SL' # Signed long
|
792
|
+
else
|
793
|
+
template = 'UL' # Unsigned long
|
794
|
+
end
|
795
|
+
when 12
|
796
|
+
# 12 BIT SIMPLY NOT IMPLEMENTED YET!
|
797
|
+
# This one is a bit tricky. I havent really given this priority so far as 12 bit image data is rather rare.
|
798
|
+
raise "Packing/unpacking pixel data of bit depth 12 is not implemented yet! Please contact the author (or edit the source code)."
|
799
|
+
else
|
800
|
+
raise ArgumentError, "Encoding/Decoding pixel data with this Bit Depth (#{depth}) is not implemented."
|
801
|
+
end
|
802
|
+
return template
|
803
|
+
end
|
804
|
+
|
805
|
+
# Collects the window level values needed to convert the original pixel
|
806
|
+
# values to presentation values.
|
807
|
+
#
|
808
|
+
# @note If some of these values are missing in the DObject instance,
|
809
|
+
# default values are used instead for intercept and slope, while center
|
810
|
+
# and width are set to nil. No errors are raised.
|
811
|
+
# @return [Array<Integer, NilClass>] center, width, intercept and slope
|
812
|
+
#
|
813
|
+
def window_level_values
|
814
|
+
center = (self['0028,1050'].is_a?(Element) == true ? self['0028,1050'].value.to_i : nil)
|
815
|
+
width = (self['0028,1051'].is_a?(Element) == true ? self['0028,1051'].value.to_i : nil)
|
816
|
+
intercept = (self['0028,1052'].is_a?(Element) == true ? self['0028,1052'].value.to_i : 0)
|
817
|
+
slope = (self['0028,1053'].is_a?(Element) == true ? self['0028,1053'].value.to_i : 1)
|
818
|
+
return center, width, intercept, slope
|
819
|
+
end
|
820
|
+
|
821
|
+
# Transfers a pre-encoded binary string to the pixel data element, either by
|
822
|
+
# overwriting the existing element value, or creating a new "Pixel Data" element.
|
823
|
+
#
|
824
|
+
# @param [String] bin a binary string containing encoded pixel data
|
825
|
+
#
|
826
|
+
def write_pixels(bin)
|
827
|
+
if self.exists?(PIXEL_TAG)
|
828
|
+
# Update existing Data Element:
|
829
|
+
self[PIXEL_TAG].bin = bin
|
830
|
+
else
|
831
|
+
# Create new Data Element:
|
832
|
+
pixel_element = Element.new(PIXEL_TAG, bin, :encoded => true, :parent => self)
|
833
|
+
end
|
834
|
+
end
|
835
|
+
|
836
|
+
end
|
837
837
|
end
|