publiclab/spectral-workbench

View on GitHub
app/models/spectrum.rb

Summary

Maintainability
D
2 days
Test Coverage
# frozen_string_literal: true

require 'rubygems'
require 'rmagick'

class Spectrum < ActiveRecord::Base
  include ActionView::Helpers::DateHelper

  # place this before the has_one :snapshot so it runs before dependent => :destroy
  before_destroy :is_deletable?

  has_many :comments, dependent: :destroy
  has_many :likes, dependent: :destroy
  has_many :tags, dependent: :destroy
  has_many :snapshots, dependent: :destroy
  has_one :processed_spectrum, dependent: :destroy
  has_and_belongs_to_many :spectra_sets

  # Paperclip
  has_attached_file :photo,
                    path: ':rails_root/public/system/:attachment/:id/:style/:filename',
                    url: '/system/:attachment/:id/:style/:filename',
                    default_url: "/images/spectral-workbench-256.png",
                    styles: {
                      thumb: '300x100!',
                      large: '800x200!'
                    }

  validates_presence_of :title, on: :create, message: "can't be blank"
  validates_presence_of :author, on: :create, message: "can't be blank"
  validates :title, length: { maximum: 60 }
  validates :title, format: { with: /\A[\w\ -\'\"]+\z/, message: 'can contain only letters, numbers, and spaces.' }
  validates :author, format: { with: /\A\w[\w\.\-_@]+\z/, message: 'can contain only letters, numbers, hyphens, underscores and periods.' }
  validates_attachment_content_type :photo, content_type: ['image/jpg', 'image/jpeg', 'image/png', 'image/gif']
  validate :validate_json

  after_save :generate_processed_spectrum
  before_save :update_calibrated
  # before_create :rename_with_id

  # validation: not allowed to destroy a spectrum someone else depends on
  def is_deletable?
    destroyable = true
    tags.each do |tag|
      destroyable &&= (tag.snapshot.nil? || tag.snapshot.has_no_dependent_spectra?)
    end
    errors[:base] << 'spectrum is depended upon by other data'
    destroyable
  end

  def sets
    spectra_sets
  end

  def latest_snapshot
    Snapshot.where(spectrum_id: id)
            .order('created_at DESC')
            .limit(1)
            .last
  end

  #  def rename_with_id
  #    extension = File.extname(self.image_file_name).downcase
  #    self.image.instance_write(:file_name, "#{Time.now.to_i}#{extension}")
  #  end

  def validate_json
    if data.nil?
      true
    else
      begin
        !!(json = ActiveSupport::JSON.decode(data)) &&
          json['lines'].class == Array &&
          json['lines'].first.class == Hash &&
          !json['lines'].first['wavelength'].nil?
      rescue StandardError
        errors[:base] << 'data not in valid JSON format'
        false
      end
    end
  end

  # to output the text "data" field as json, not string data
  def json
    json = as_json(except: [:data])
    json['data'] = ActiveSupport::JSON.decode(clean_json)
    json
  end

  def update_calibrated
    self.calibrated = is_calibrated?
    true
  end

  def user
    User.find user_id
  end

  # this is used to determine if we should force user into 2.0 interface:
  def has_operations
    !Tag.where(spectrum_id: id)
        .collect(&:name)
        .reject { |s| s.match(':').nil? }.empty?
  end

  def self.weekly_tallies
    # past 52 weeks of data
    weeks = {}
    (0..52).each do |week|
      weeks[52 - week] = Spectrum.select(:created_at).where(created_at: Time.now - week.weeks..Time.now - (week - 1).weeks).count
    end
    weeks
  end

  # finds the brightest row of the image and uses that as its sample row
  def find_brightest_row
    image = Magick::ImageList.new('public' + (photo.url.split('?')[0]).gsub('%20', ' '))
    brightest_row = 0
    brightest = 0
    # sum brightness for each row
    (0..(image.rows - 1)).each do |row|
      brightness = 0
      px = image.export_pixels(0, row, image.columns, 1, 'RGB')
      (0..(image.columns / 3 - 1)).each do |col|
        r = px[col * 3]
        g = px[col * 3 + 1]
        b = px[col * 3 + 2]
        brightness += (r - g).abs + (g - b).abs + (b - r).abs # difference between colors
        brightness += (r + g + b) # overall brightness
      end
      if brightness > brightest
        brightest_row = row
        brightest = brightness
      end
    end
    brightest_row
  end

  # extracts serialized data from the top row of the stored image
  def extract_data
    pixels = []

    image = Magick::ImageList.new('public' + (photo.url.split('?')[0]).gsub('%20', ' '))
    # saved sample_row may be greater than image height, so temporarily compensate,
    # but preserve sample_row in case we rotate back or something
    self.sample_row = image.rows - 2 if sample_row > image.rows
    row = image.export_pixels(0, sample_row, image.columns, 1, 'RGB')

    (0..(row.length / 3 - 1)).each do |p|
      r = row[p * 3] / 255
      g = row[p * 3 + 1] / 255
      b = row[p * 3 + 2] / 255
      pixels << '{"pixel":' + p.to_s + ',"wavelength":null,"average":' + ((r + g + b) / 3).to_s + ',"r":' + r.to_s + ',"g":' + g.to_s + ',"b":' + b.to_s + '}'
    end

    t = title.delete("'").delete('"')
    # save it internally
    s = '{"name":"' + t + '","lines": ['
    s += pixels.join(',')
    s += ']}'
    self.data = s
    s
  end

  # {wavelength:null,average:0,r:0,g:0,b:0}
  def scale_data(start_w, end_w)
    d = ActiveSupport::JSON.decode(clean_json)
    i = 0
    d['lines'].each do |line|
      line['wavelength'] = (start_w.to_f + i * ((end_w.to_f - start_w.to_f) * 1.00 / d['lines'].length)).round(2)
      i += 1
    end
    self.data = ActiveSupport::JSON.encode(d)
    self
  end

  def calibration
    Spectrum.find powertag('calibration').to_i
  end

  def is_calibrated?
    if data.nil?
      false
    else
      if tags.collect(&:key).include?('calibrate') || tags.collect(&:key).include?('linearCalibration')
        true
      else
        begin
          d = ActiveSupport::JSON.decode(clean_json)
          !d.nil? && !d['lines'].nil? && !d['lines'].first['wavelength'].nil?
        rescue StandardError
          false
        end
      end
    end
  end

  def calibrate(axis_one, wavelength1, axis_two, wavelength2)
    extract_data
    d = ActiveSupport::JSON.decode(clean_json)
    i = 0
    stepsize = ((wavelength2.to_f - wavelength1.to_f) * 1.00 / (axis_two.to_f - axis_one.to_f))
    startwavelength = wavelength1.to_f - (stepsize * axis_one.to_f)
    d['lines'].each do |line|
      line['wavelength'] = (startwavelength + i * stepsize).round(2)
      i += 1
    end
    # this may cause trouble:
    reverse if (wavelength1 < wavelength2 && axis_one > axis_two) || (wavelength1 > wavelength2 && axis_one < axis_two)
    self.data = ActiveSupport::JSON.encode(d)
    self
  end

  # turns ' into " and puts "" around key names, to produce valid json
  def clean_json
    data.tr("'", '"').gsub(/([a-z]+):/, '"\\1":')
  end

  def forks
    Tag.where('name LIKE (?)', 'forked:' + id.to_s + '%').collect(&:spectrum)
  end

  def fork(user)
    new = dup
    new.author = user.login
    new.user_id = user.id
    new.photo = photo
    new.save!
    new.tag("forked:#{id}", user.id)
    # record "now" timestamp
    now = Time.now
    # now copy over all tags, in order:
    tags.each do |tag|
      next if tag.key == 'forked'

      newtag = new.tag(tag.name, user.id)
      now += 1 # increment now by one second per tag
      # preserve created_at, for tag ordering; we should be able to tell based on spectrum created_at
      newtag.created_at = now # forward-date each by 1 second
      if tag.needs_snapshot? && tag.snapshot && !tag.snapshot.data.nil?
        newtag.create_snapshot(tag.snapshot.data)
        newtag.snapshot.created_at = newtag.created_at
      end
      newtag.save
    end
    Spectrum.find new.id # refetch it to get all the tags, for some reason needed by tests
  end

  # clones calibration from another spectrum (preferably taken from the same device)
  # deprecating in 2.0 in favor of client-side powertag-based cloneCalibration
  def clone_calibration(clone_id)
    clone_source = Spectrum.find clone_id
    d = ActiveSupport::JSON.decode(clean_json)
    cd = clone_source.latest_json_data
    # assume linear:
    lines = cd['lines']
    lines = lines.reverse if clone_source.is_flipped
    length = lines.length
    start_wave_length = lines[0]['wavelength'].to_f
    end_wave_length = lines[length - 1]['wavelength'].to_f
    stepsize = (end_wave_length - start_wave_length) / d['lines'].length
    i = 0
    d['lines'].each do |line|
      line['wavelength'] = if cd['lines'][i] && d['lines'].length == length # if they're the same size, exactly
                             lines[i]['wavelength']
                           else
                             (start_wave_length + (i * stepsize)).round(2)
                           end
      i += 1
    end
    tag("calibrate:#{clone_id}", user_id)
    # figure out how to know when to reverse based on a cloned calibration... maybe we need to store reversal...
    # self.reverse if (wavelength1 < wavelength2 && x1 > x2) || (wavelength1 > wavelength2 && x1 < x2)
    self.data = ActiveSupport::JSON.encode(d)
    self.reversed = clone_source.reversed
    self
  end

  # rotate clockwise
  def rotate
    image = Magick::ImageList.new('public' + (photo.url.split('?')[0]).gsub('%20', ' '))
    image.rotate!(-90)
    image.write('public' + photo.url)
    photo.reprocess!
  end

  # horizontally flips image to match reversed spectrum, toggles 'reversed' flag
  def reverse
    image = Magick::ImageList.new('public' + (photo.url.split('?')[0]).gsub('%20', ' '))
    image.flop!
    image.write('public' + photo.url)
    self.reversed = !reversed
    photo.reprocess!
  end

  def image_from_dataurl(data)
    # remove image/png:base,
    data = data.split(',').pop

    # decode base64:
    data = Base64.decode64(data)

    self.photo = Paperclip.string_to_file('capture.png', 'image/png', data)
  end

  def range
    powertag('range').split('-').map(&:to_i)
  end

  def add_snapshot(tag, data)
    snapshots << Snapshot.create(
      user_id: tag.spectrum.user_id,
      tag_id: tag.id,
      data: data
    )

    snapshots.last
  end

  def tag(name, user_id)
    tag = Tag.new(
      name: name.strip,
      user_id: user_id,
      spectrum_id: id
    )
    tag.save
    tag
  end

  def normaltags
    tags.select { |tag| tag.name.match(':').nil? }
  end

  def remove_powertags(tag_prefix)
    tags.each do |tag|
      tag.delete if tag.name.split(':').first == tag_prefix
    end
  end

  def forked
    has_powertag('forked')
  end

  def created_at_in_words
    time_ago_in_words(created_at)
  end

  def data_as_hash
    ActiveSupport::JSON.decode(clean_json)['lines']
  end

  def bin_data(binsize)
    bins = {}
    count = {}

    data_as_hash.each do |datum|
      bin = (datum['wavelength'].to_f / binsize).to_i
      bins[bin] = 0 if bins[bin].nil?
      bins[bin] += datum['average'].to_i
      count[bin] = 0 if count[bin].nil?
      count[bin] += 1
    end
    result = {}
    bins.each_with_index do |bin, i|
      bin[1] = bin[1] / count[i] if count[i].to_i.positive?
      result[bin[0].to_i * binsize] = bin[1]
    end
    result
  end

  # compare self to other spectrum, return a # score
  def compare(spectrum_id)
    # both must be calibrated (do we need a flag for this?)

    # find difference, subtract average, square it
    # find overall average
    sum = 0
    data = bin_data(10)
    other = Spectrum.find(spectrum_id).bin_data(10)
    difference = 0
    difference_sum = 0
    data.each do |bin|
      sum += bin[1]
      difference_sum += 1
      difference += (other[bin[1]] - data[bin[1]])**2 unless other[bin[1]].nil? || data[bin[1]].nil?
    end

    # can we generate a "score" of the match? how close it was?
    difference
  end

  def find_match_in_set(set_id)
    set = sort_set(set_id)
    # find lowest score, return it
    set = set.sort_by { |a| a[1] }
    Spectrum.find set[0][0].to_i
  end

  def sort_set(set_id)
    set = SpectraSet.find set_id
    scored = {}

    set.spectra.collect(&:id).each do |id|
      scored[id] = compare(id) if id.to_i != self.id
    end
    scored
  end

  # used in capture interface for displaying latest calibration
  def wavelength_range
    d = latest_json_data
    range = [d['lines'][0]['wavelength'], d['lines'][d['lines'].length - 1]['wavelength']]
    # always returns range in ascending order
    range.reverse! if range.first && (range.first > range.last)
    range
  end

  # new spectrum from string of "wavelength:value,wavelength:value"
  def self.new_from_string(data)
    json = "{ 'lines': ["
    cols = data.split(',')
    cols.each_with_index do |datum, i|
      datum = datum.split(':')
      json += "{'wavelength':" + datum[0] + ",'average':" + datum[1] + '}'
      json += ',' if i < cols.length - 1
    end
    json += ']}'
    new(data: json)
  end

  def has_tag(name)
    !Tag.where(name: name).where(spectrum_id: id).empty?
  end

  def remove_tag(name)
    Tag.where(name: name).where(spectrum_id: id)[0].destroy
  end

  def toggle_tag(name, user_id)
    if has_tag(name)
      remove_tag(name)
    else
      tag(name, user_id)
    end
  end

  # no colon required
  def has_powertag(name)
    !powertags(name).empty?
  end

  # no colon required
  def powertags(name)
    Tag.where('name LIKE (?)', name + ':%').where(spectrum_id: id)
  end

  # if we can safely assume there's only one - no colon required
  def powertag(name)
    powertags(name).first.name.split(':').last
  end

  # if it has horizontally flipped input image: red is at left
  # newly calculated based on linearCalibration tag having x1 > x2
  def is_flipped
    has_powertag('linearCalibration') && powertag('linearCalibration').split('-')[0].to_f > powertag('linearCalibration').split('-')[1].to_f
  end

  def liked_by(user_id)
    Like.where(spectrum_id: id).find_by_user_id(user_id)
  end

  # notify each commenter about a new comment
  def notify_commenters(new_comment, current_user)
    emails = []
    comments.each do |comment|
      emails << comment.email if comment != new_comment && (!current_user || (comment.author != current_user.login)) && comment.author != author
    end
    # send for every commenter, whether they are a registered user or not...
    emails.uniq.each do |email|
      registered_commenter = User.find_by_email(email)
      if registered_commenter
        UserMailer.commenter_notification(self, new_comment, registered_commenter)
      else
        UserMailer.unregistered_commenter_notification(self, new_comment, email)
      end
    end
  end

  # Process the spectrum for the "Closest Match Module"
  # -- run after_save
  def generate_processed_spectrum
    if calibrated
      if processed_spectrum
        processed_spectrum.update_attributes(generate_hashed_values)
      else
        self.processed_spectrum = ProcessedSpectrum.new(generate_hashed_values)
      end
      processed_spectrum.save
    end
  end

  def latest_json_data
    if snapshots.count.positive?
      ActiveSupport::JSON.decode(snapshots.last.data)
    else
      ActiveSupport::JSON.decode(clean_json)
    end
  end

  # Generate the values hash for the processed spectrum
  def generate_hashed_values
    lines = latest_json_data['lines']

    values = {}
    counts = {}
    types = %w(a r g b)

    labels = {}
    labels['a'] = 'average'
    labels['r'] = 'r'
    labels['g'] = 'g'
    labels['b'] = 'b'

    bins = (10...1500).step(10)

    values['spectrum_id'] = id

    bins.each do |bin|
      types.each do |type|
        values["#{type}#{bin}"] = 0
      end
      counts[bin] = 0
    end

    lines.each do |line|
      wlength = line['wavelength']

      next unless !wlength.nil? && wlength.class != String

      # Some spectrums have "NaN" as wavelength and a, r, g, b values
      # Also, this protects from the condition where the wlength is nil
      bins_to_consider = get_bins(line['wavelength'].round)

      bins_to_consider.each do |bin|
        types.each do |type|
          values["#{type}#{bin}"] += line[labels[type]].round
        end
        counts[bin] += 1
      end
    end

    # Time to average the values and return

    bins.each do |bin|
      next unless (counts[bin]).positive?

      types.each do |type|
        values["#{type}#{bin}"] /= counts[bin]
      end
    end

    values
  end

  # Given a wavelength, decide which bin(s) it should fall into
  def get_bins(wavelength)
    base = (wavelength / 10) * 10
    diff = wavelength - base

    if base < 10 || base > 1490
      return [] # Skipping it off
    end

    if diff > 6 # This is in the next bin
      if base + 10 > 1490
        [] # Falls into 1500. So, Skip
      else
        [base + 10]
      end
    elsif diff < 4 # This is in this bin
      [base]
    else
      # This is in both the present and next bin
      if base + 10 > 1490
        [base] # 1500 again. Skip
      else
        [base, base + 10]
      end
    end
  end

  def find_similar(range)
    proc_nil = false

    if processed_spectrum.nil?
      spectra = []
      proc_nil = true
    else
      spectra = processed_spectrum.closest_match(range, 20)
    end

    # Some spectrums have many matches with range of 100. Some have very few.
    # So why stop just at 100? Something dynamic would be good, though takes some extra time
    # Implementing a sort of binary search for best spectra matching.

    # Time to make our matches more meaningful.

    range_visits = [range] # To check the ranges visited

    # This loop will take 10 iterations at maximum.
    while !proc_nil && ((spectra.size < 2) || (spectra.size > 6))
      range = if spectra.size > 6 # Need to reduce the range
                range - 10
              else
                range + 10
              end

      break if range_visits.member?(range) || (range < 10) || (range > 150)

      range_visits.push(range)
      spectra = processed_spectrum.closest_match(range, 20)
    end

    spectra
  end

  private

  def spectrum_params
    params.require(:spectrum).permit(:title, :author, :user_id, :notes, :photo, :video_row, :data)
  end
end