# encoding: utf-8 require 'rubygems' # require 'sqlite3' require 'fileutils' require 'metamri/nifti_builder' # A #RawImageDataset defines a single 3D or 4D image, i.e. either a volume or a time series # of volumes. This encapsulation will provide easy manipulation of groups of raw # image files including basic reconstruction. class RawImageDataset # The directory that contains all the raw images and related files that make up # this data set. attr_reader :directory # An array of #RawImageFile objects that compose the complete data set. attr_reader :raw_image_files # From the first raw image file in the dataset attr_reader :series_description # From the first raw image file in the dataset attr_reader :rmr_number # From the first raw image file in the dataset attr_reader :timestamp # From the first raw image file in the dataset attr_reader :exam_number # A key string unique to a dataset composed of the rmr number and the timestamp. attr_reader :dataset_key # the file scanned attr_reader :scanned_file # the scanner source attr_reader :scanner_source # A #RawImageDatasetThumbnail object that composes the thumbnail for the dataset. attr_reader :thumbnail # A Description of the Study as listed in the DICOM Header attr_reader :study_description # A Description of the Protocol as listed in the DICOM Header attr_reader :protocol_name # Scan Tech Initials attr_reader :operator_name # Patient "Name", usually StudyID or ENUM attr_reader :patient_name # DICOM Series UID attr_reader :dicom_series_uid # DICOM Study UID attr_reader :dicom_study_uid # Tag Hash of DICOM Keys attr_reader :dicom_taghash # Array of Read Error Strings attr_reader :read_errors # head coil attr_reader :mri_coil_name # staion name attr_reader :mri_station_name # mri model name attr_reader :mri_manufacturer_model_name # * dir: The directory containing the files. # * files: An array of #RawImageFile objects that compose the complete data set. # # Initialization raises errors in several cases: # * directory doesn't exist => IOError # * any of the raw image files is not actually a RawImageFile => IndexError # * series description, rmr number, or timestamp cannot be extracted from the first RawImageFile => IndexError def initialize(directory, raw_image_files) @read_errors = Array.new @directory = File.expand_path(directory) raise(IOError, "#{@directory} not found.") if not File.directory?(@directory) raise(IOError, "No raw image files supplied.") unless raw_image_files # If only a single raw_image_file was supplied, put it into an array for processing. raw_image_files = [raw_image_files] if raw_image_files.class.to_s == "RawImageFile" raw_image_files.each do |im| raise(IndexError, im.to_s + " is not a RawImageFile") if im.class.to_s != "RawImageFile" end @raw_image_files = raw_image_files @series_description = @raw_image_files.first.series_description validates_metainfo_for :series_description, :msg => "No series description found" @rmr_number = @raw_image_files.first.rmr_number raise(IndexError, "No rmr found") if @rmr_number.nil? @timestamp = get_earliest_timestamp raise(IndexError, "No timestamp found") if @timestamp.nil? @dataset_key = @rmr_number + "::" + @timestamp.to_s @scanned_file = @raw_image_files.first.filename raise(IndexError, "No scanned file found") if @scanned_file.nil? @scanner_source = @raw_image_files.first.source raise(IndexError, "No scanner source found") if @scanner_source.nil? @exam_number = @raw_image_files.first.exam_number.nil? ? nil : @raw_image_files.first.exam_number validates_metainfo_for :exam_number, :msg => "No study id / exam number found", :optional => true @study_description = @raw_image_files.first.study_description validates_metainfo_for :study_description, :msg => "No study description found" if dicom? @protocol_name = @raw_image_files.first.protocol_name validates_metainfo_for :protocol_name, :msg => "No protocol name found" if dicom? @operator_name = @raw_image_files.first.operator_name validates_metainfo_for :operator_name, :optional => true if dicom? @patient_name = @raw_image_files.first.patient_name validates_metainfo_for :patient_name if dicom? @dicom_series_uid = @raw_image_files.first.dicom_series_uid validates_metainfo_for :dicom_series_uid if dicom? @dicom_study_uid = @raw_image_files.first.dicom_study_uid validates_metainfo_for :dicom_study_uid if dicom? @dicom_taghash = @raw_image_files.first.dicom_taghash validates_metainfo_for :dicom_taghash if dicom? @image_uid = @raw_image_files.first.image_uid validates_metainfo_for :image_uid if pfile? @mri_coil_name = @raw_image_files.first.mri_coil_name @mri_station_name = @raw_image_files.first.mri_station_name @mri_manufacturer_model_name = @raw_image_files.first.mri_manufacturer_model_name $LOG ||= Logger.new(STDOUT) end # Prints a "success" dot or error mesage if any errors in @read_errors. def print_scan_status if @read_errors.empty? print "."; STDOUT.flush else puts @read_errors.join("; ") end end # Generates an SQL insert statement for this dataset that can be used to # populate the Johnson Lab rails TransferScans application database backend. The # motivation for this is that many dataset inserts can be collected into one db # transaction at the visit level, or even higher when doing a whole file system # scan. def db_insert(visit_id) "INSERT INto image_datasets (rmr, series_description, path, timestamp, created_at, updated_at, visit_id, glob, rep_time, bold_reps, slices_per_volume, scanned_file, 'dicom_study_uid') VALUES ('#{@rmr_number}', '#{@series_description}', '#{@directory}', '#{@timestamp.to_s}', '#{DateTime.now}', '#{DateTime.now}', '#{visit_id}', '#{self.glob}', '#{@raw_image_files.first.rep_time}', '#{@raw_image_files.first.bold_reps}', '#{@raw_image_files.first.num_slices}', '#{@scanned_file}' )" end def db_update(dataset_id) "UPDATE image_datasets SET rmr = '#{@rmr_number}', series_description = '#{@series_description}', path = '#{@directory}', timestamp = '#{@timestamp.to_s}', updated_at = '#{DateTime.now.to_s}', glob = '#{self.glob}', rep_time = '#{@raw_image_files.first.rep_time}', bold_reps = '#{@raw_image_files.first.bold_reps}', slices_per_volume = '#{@raw_image_files.first.num_slices}', scanned_file = '#{@scanned_file}' WHERE id = '#{dataset_id}'" end def db_fetch "SELECT * FROM image_datasets WHERE rmr = '#{@rmr_number}' AND path = '#{@directory}' AND timestamp LIKE '#{@timestamp.to_s.split(/\+|Z/).first}%'" end # Returns a hash of attributes used for insertion into active record. # Options: :thumb => FileHandle to thumbnail includes a thumbnail. def attributes_for_active_record(options = {}) attrs = {} # If the thumbnail is present and valid, add it to the hash. # Otherwise don't add the key, or paperclip will delete the attachments (it deletes when given nil) if options.has_key?(:thumb) thumbnail = options[:thumb] unless (thumbnail.class == File || thumbnail == nil) raise(IOError, "Thumbnail #{options[:thumb]} must be a #File instead of #{thumbnail.class}.") end attrs[:thumbnail] = thumbnail end { :rmr => @rmr_number, :series_description => @series_description, :path => @directory, :timestamp => @timestamp.to_s, :glob => glob, :rep_time => @raw_image_files.first.rep_time, :bold_reps => @raw_image_files.first.bold_reps, :slices_per_volume => @raw_image_files.first.num_slices, :scanned_file => @scanned_file, :dicom_series_uid => @dicom_series_uid, :dicom_taghash => @dicom_taghash, :image_uid => @image_uid, :mri_coil_name => @mri_coil_name, :mri_station_name => @mri_station_name, :mri_manufacturer_model_name => @mri_manufacturer_model_name }.merge attrs end def create_thumbnail @thumbnail = RawImageDatasetThumbnail.new(self) @thumbnail.create_thumbnail end def thumbnail_for_active_record # Ensure a thumbnail has been created. create_thumbnail unless @thumbnail return File.open(@thumbnail.path) end =begin rdoc Implements an api for changing image datasets into usable nifti files. Pass in an output path and filename. The to3d code is applied as a mixed-in module. Returns the to3d command that creates the specified options. =end def to_nifti(nifti_output_directory, nifti_filename, input_options = {} ) # Handle the business logic for choosing the right Nifti Builder here. # Currently just extend the default unknown builder, since that's the only one that exists. if true nifti_output_directory = File.join(nifti_output_directory, 'unknown') if input_options[:append_modality_directory] extend(UnknownImageDataset) end nifti_conversion_command, nifti_output_file = self.dataset_to_nifti(nifti_output_directory, nifti_filename, input_options) return nifti_conversion_command, nifti_output_file end =begin rdoc Uses to3d to create the nifti file as specified by to_nifti. Returns a path to the created dataset as a string if successful. =end def to_nifti!(nifti_output_directory, nifti_filename, input_options = {} ) begin nifti_conversion_command, nifti_output_file = to_nifti(nifti_output_directory, nifti_filename, input_options) puts nifti_conversion_command begin system "#{nifti_conversion_command}" raise ScriptError, "#{nifti_output_file} does not exist." unless File.exist?(nifti_output_file) rescue ScriptError => e input_options[:no_timing_options] = true nifti_conversion_command, nifti_output_file = to_nifti(nifti_output_directory, nifti_filename, input_options) system "#{nifti_conversion_command}" end raise(IOError, "Could not convert image dataset: #{@directory} to #{nifti_output_file}") unless $? == 0 rescue IOError => e $LOG.warn "-- Warning: #{e.message}" end return nifti_conversion_command, nifti_output_file end # Returns a globbing wildcard that is used by to3D to gather files for # reconstruction. If no compatible glob is found for the data set, nil is # returned. This is always the case for pfiles. For example if the first file in # a data set is I.001, then: # dataset.glob # => "I.*" # including the quotes, which are necessary becuase some data sets (functional dicoms) # have more component files than shell commands can handle. def glob case @raw_image_files.first.filename when /^E.*dcm$/ return 'E*.dcm' when /\.dcm$/ return '*.dcm' when /^I\./ return 'I.*' when /^I/ return 'I*.dcm' when /.*\.\d{3,4}/ return '*.[0-9]*' when /\.0/ return '*.0*' else return nil end # Note - To exclude just yaml files we could also just use the bash glob # '!(*.yaml), but we would have to list all exclusions. This may turn # out easier in the long run. end def file_count unless @file_count if @raw_image_files.first.dicom? or @raw_image_files.first.geifile? @file_count = Dir.open(@directory).reject{ |branch| /(^\.|.yaml$)/.match(branch) }.length elsif @raw_image_files.first.pfile? @file_count = 1 elsif @raw_image_files.first.scan_archive_h5_json? @file_count = 1 else raise "File not recognized as dicom or pfile or scan_archive_h5_json." end end return @file_count end # Creates an Hirb Table for pretty output of dataset info. # It takes an array of either RawImageDatasets or RawImageDatasetResources def self.to_table(datasets) if datasets.first.class.to_s == "RawImageDatasetResource" datasets = datasets.map { |ds| ds.to_metamri_image_dataset } end Hirb::Helpers::AutoTable.render( datasets.sort_by{ |ds| [ds.timestamp, File.basename(ds.directory)] }, :headers => { :relative_dataset_path => 'Dataset', :series_details => 'Series Details', :file_count => 'File Count'}, :fields => [:relative_dataset_path, :series_details, :file_count], :description => false # Turn off rendering row count description at bottom. ) end # Returns a relative filepath to the dataset. Handles dicoms by returning the # dataset directory, and pfiles by returning either the pfile filename or, # if passed a visit directory, the relative path from the visit directory to # the pfile (i.e. P00000.7 or raw/P00000.7). def relative_dataset_path(visit_dir = nil) image_file = @raw_image_files.first case image_file.file_type when 'dicom', 'geifile' relative_dataset_path = File.basename(directory) when 'pfile' full_dataset_path = Pathname.new(File.join(directory, image_file.filename)) if visit_dir relative_dataset_path = full_dataset_path.relative_path_from(visit_dir) else relative_dataset_path = image_file.filename end when 'scan_archive_h5_json' full_dataset_path = Pathname.new(File.join(directory, image_file.filename)) if visit_dir relative_dataset_path = full_dataset_path.relative_path_from(visit_dir) else relative_dataset_path = image_file.filename end else raise "Cannot identify #{@raw_image_files.first.filename}" end return relative_dataset_path end # Reports series details, including description and possibly image quality # check comments for #RawImageDatasetResource objects. def series_details @series_description end # Helper predicate method to check whether the dataset is a DICOM dataset or not. # This just sends dicom? to the first raw file in the dataset. def dicom? @raw_image_files.first.dicom? end # Helper predicate method to check whether the dataset is a DICOM dataset or not. # This just sends dicom? to the first raw file in the dataset. def pfile? @raw_image_files.first.pfile? end # Helper predicate method to check whether the dataset is a DICOM dataset or not. # This just sends dicom? to the first raw file in the dataset. def geifile? @raw_image_files.first.geifile? end def scan_archive_h5_json? @raw_image_files.first.scan_archive_h5_json? end private # Gets the earliest timestamp among the raw image files in this dataset. def get_earliest_timestamp @timestamp = (@raw_image_files.sort_by { |i| i.timestamp }).first.timestamp end # Directory Basename is hardcoded for Pretty Printing using Hirb, which takes symbols as method names for its columns. def directory_basename File.basename(@directory) end # Metamri will return both dicom_series_uid and image_uid for ActiveRecord # Correct UID choosing will happen in the Panda. # But, here's a shortcut to a pfile?/dicom? ternary for correct uid for dataset def dataset_uid @raw_image_files.first.dicom_series_uid ? @raw_image_files.first.dicom_series_uid : @raw_image_files.first.image_uid end # Ensure that metadata is present in instance variables. # # Raises an IndexError if supplied instance variable is nil unless :optional, # and adds a message to the @read_errors array. # # === Parameters # # * info_variable -- A string (not including the @ sign) to check to ensure not blank and not empty. # # === Options # # * :msg -- An optional message to be added to @read_errors (defaults to "Couldn't find ") # * :optional -- A boolean to allow adding the error to the array as a warning but not breaking with an error. # # === Examples # # validates_metainfo_for :study_description, :msg => "No study description found", :optional => true # def validates_metainfo_for(info_variable, options = {}) raise StandardError, "#{info_variable} must be a symbol" unless info_variable.kind_of? Symbol data = self.instance_variable_get("@" + info_variable.to_s) if data.nil? || data.empty? message = options[:msg] || "Couldn't find #{info_variable.to_s}" @read_errors << message raise IndexError, message unless options[:optional] end end end #### END OF CLASS ####