Class: Image

Overview

An Image is just that, as it is stored in the filesystem. No additional metadata beyond file descriptors is included here. More broadly we consider an Image to be the digital encoding of a radiation-derived observation. This lets Images conceptually include things like 3D volumetric models, ASCII drawings, or other data that were generated from (originated based on) light (radiation) interacting with life.

This class relies on the paperclip gem and the ImageMagik app to link, store and manipulate images.

Defined Under Namespace

Modules: Sled

Constant Summary collapse

MISSING_IMAGE_PATH =
'/public/images/missing.jpg'.freeze
GRAPH_ENTRY_POINTS =
[:depictions]
DEFAULT_SIZES =
{
  thumb: { width: 100, height: 100 },
  medium: { width: 300, height: 300 }
}.freeze

Constants included from SoftValidation

SoftValidation::ANCESTORS_WITH_SOFT_VALIDATIONS

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from SoftValidation

#clear_soft_validations, #fix_for, #fix_soft_validations, #soft_fixed?, #soft_valid?, #soft_validate, #soft_validated?, #soft_validations, #soft_validators

Methods included from Shared::IsData

#errors_excepting, #full_error_messages_excepting, #identical, #is_community?, #is_destroyable?, #is_editable?, #is_in_use?, #is_in_users_projects?, #metamorphosize, #similar

Methods included from Shared::Attributions

#attributed?, #reject_attribution

Methods included from Shared::Citations

#cited?, #mark_citations_for_destruction, #nomenclature_date, #origin_citation_source_id, #reject_citations, #requires_citation?, #sources_by_topic_id

Methods included from Shared::ProtocolRelationships

#protocolled?, #reject_protocols

Methods included from Shared::Tags

#reject_tags, #tag_with, #tagged?, #tagged_with?

Methods included from Shared::Notes

#concatenated_notes_string, #reject_notes

Methods included from Shared::Identifiers

#dwc_occurrence_id, #identified?, #next_by_identifier, #previous_by_identifier, #reject_identifiers, #uri, #uuid

Methods included from Housekeeping

#has_polymorphic_relationship?

Methods inherited from ApplicationRecord

transaction_with_retry

Instance Attribute Details

#filename_depicts_objectObject

ANY non-blank? value here will attempt to also create a depiction for the Image, linking it to a CollectionObject



66
67
68
# File 'app/models/image.rb', line 66

def filename_depicts_object
  @filename_depicts_object
end

#heightInteger

Returns the height of the source image in px.

Returns:

  • (Integer)

    the height of the source image in px



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
# File 'app/models/image.rb', line 49

class Image < ApplicationRecord
  include Housekeeping
  include Shared::Identifiers
  include Shared::Notes
  include Shared::Tags
  include Shared::ProtocolRelationships
  include Shared::Citations
  include Shared::Attributions
  include Shared::IsData
  include SoftValidation

  include Image::Sled

  attr_accessor :rotate

  # ANY non-blank? value here will attempt to also create a depiction 
  # for the Image, linking it to a CollectionObject
  attr_accessor :filename_depicts_object

  MISSING_IMAGE_PATH = '/public/images/missing.jpg'.freeze

  GRAPH_ENTRY_POINTS = [:depictions]

  DEFAULT_SIZES = {
    thumb: { width: 100, height: 100 },
    medium: { width: 300, height: 300 }
  }.freeze

  has_one :sled_image, dependent: :destroy, inverse_of: :image

  has_many :depictions, inverse_of: :image, dependent: :restrict_with_error

  has_many :collection_objects, through: :depictions, source: :depiction_object, source_type: 'CollectionObject'
  has_many :otus, through: :depictions, source: :depiction_object, source_type: 'Otu'
  has_many :taxon_names, through: :otus

  after_validation :stub_depiction, if: Proc.new {|n| !n.filename_depicts_object.blank?}
  before_save :extract_tw_attributes

  # also using https://github.com/teeparham/paperclip-meta
  has_attached_file :image_file,
    styles: {
      thumb: [ "#{DEFAULT_SIZES[:thumb][:width]}x#{DEFAULT_SIZES[:thumb][:height]}>", :png ] ,
      medium: [ "#{DEFAULT_SIZES[:medium][:width]}x#{DEFAULT_SIZES[:medium][:height]}>", :jpg ] },
  default_url: MISSING_IMAGE_PATH,
  filename_cleaner: Utilities::CleanseFilename,
  processors: [:rotator]

  #:restricted_characters => /[^A-Za-z0-9\.]/,
  validates_attachment_content_type :image_file, content_type: /\Aimage\/.*\Z/
  validates_attachment_presence :image_file
  validate :image_dimensions_too_short

  validates_uniqueness_of :image_file_fingerprint, scope: :project_id

  accepts_nested_attributes_for :sled_image, allow_destroy: true

  accepts_nested_attributes_for :depictions, allow_destroy: false

  # This is bad and you should feel bad if your digitization workflow uses it.
  def stub_depiction
    identifier = File.basename(
      image_file.queued_for_write[:original].original_filename,
      '.*'
    )

    co = CollectionObject.joins(:identifiers).where(
      identifiers: {cached: identifier},
      project_id: Current.project_id,
    ).first

    if co.nil?
      errors.add(:base, 'filename does not match any known CollectionObject identifier')
      return
    end

    depictions.build(
      depiction_object_id: co.id,
      depiction_object_type: 'CollectionObject',
      by: Current.user_id,
      project_id: Current.project_id
    )
  end

  # Replaces Image.create!
  def self.deduplicate_create(image_params)
    image = Image.new(image_params)

    if i = Image.where(project_id: Current.project_id, image_file_fingerprint: image.image_file_fingerprint).first
      return i
    else
      return image
    end
  end

  def sqed_depiction
    depictions.joins(:sqed_depiction).first&.sqed_depiction
  end

  # TODO:
  # Deprecated, once images are de-duplicated
  #   this will be removed
  # @return [Boolean]
  def has_duplicate?
    Image.where(image_file_fingerprint: self.image_file_fingerprint).count > 1
  end

  # TODO:
  # Deprecated, once images are de-duplicated
  #   this will be removed. No duplicate images can now be created
  # @return [Array]
  def duplicate_images
    Image.where(image_file_fingerprint: self.image_file_fingerprint).not_self(self).to_a
  end

  # @return [Hash]
  # returns a hash of EXIF data if present, empty hash if not.do
  # EXIF data tags/specifications -  http://web.archive.org/web/20131018091152/http://exif.org/Exif2-2.PDF
  def exif
    ret_val = {} # return value

    unless self.new_record? # only process if record exists
      tmp     = `identify -format "%[EXIF:*]" #{self.image_file.url}` # returns a string (exif:tag=value\n)
      # following removes the exif, spits and recombines string as a hash
      ret_val = tmp.split("\n").collect { |b| b.gsub('exif:', '').split('=') }
        .inject({}) { |hsh, c| hsh.merge(c[0] => c[1]) }
      # might be able to tmp.split("\n").collect { |b|
      # b.gsub("exif:", "").split("=")
      # }.inject(ret_val) { |hsh, c|
      #   hsh.merge(c[0] => c[1])
      # }
    end

    ret_val # return
  end

  # TODO: move to /lib
  # @return [Nil]
  #  currently handling this client side
  def gps_data
    # if there is EXIF data, pulls out geographic coordinates & returns hash of lat/long in decimal degrees
    # (5 digits after decimal point if available)
    # EXIF gps information is in http://web.archive.org/web/20131018091152/http://exif.org/Exif2-2.PDF section 4.6.6
    # note that cameras follow specifications, but EXIF data can be edited manually and may not follow specifications.

    # check if gps data is in d m s (could be edited manually)
    #   => format dd/1,mm/1,ss/1 or dd/1,mmmm/100,0/1 or 40/1, 5/1, 314437/10000
    # N = +
    # S = -
    # E = +
    # W = -
    # Altitude should be based on reference of sea level
    # GPSAltitudeRef is 0 for above sea level, and 1 for below sea level

    # From discussion with Jim -
    # create a utility library called "GeoConvert" and define single method
    # that will convert from degrees min sec to decimal degree
    # - maybe 2 versions? - one returns string, other decimal?
  end

  # Returns the true, unscaled height/width ratio
  # @return [Float]
  def hw_ratio
    raise if height.nil? || width.nil? # if they are something has gone badly wrong
    return (height.to_f / width.to_f)
  end

  # rubocop:disable Style/StringHashKeys
  # used in ImageHelper#image_thumb_tag
  # asthetic scaling of very narrow images in thumbnails
  # @return [Hash]
  def thumb_scaler
    a = self.hw_ratio
    if a < 0.6
      { 'width' => 200, 'height' => 200 * a}
    else
      {}
    end
  end
  # rubocop:enable Style/StringHashKeys

  # the scale factor is typically the same except in a few cases where we skew small thumbs
  # @param [Symbol] size
  # @return [Float]
  def width_scale_for_size(size = :medium)
    (width_for_size(size).to_f / width.to_f)
  end

  # @param [Symbol] size
  # @return [Float]
  def height_scale_for_size(size = :medium)
    height_for_size(size).to_f / height.to_f
  end

  # @param [Symbol] size
  # @return [Float]
  def width_for_size(size = :medium)
    a = self.hw_ratio
    case size
    when :thumb
      a < 0.6 ? 200.0 : ((width.to_f / height.to_f ) * 160.0)
    when :medium
      a < 1 ? 640.0 : 640.0 / a
    when :big
      a < 1 ? 1600.0 : 1600.0 / a
    when :original
      width
    else
      nil
    end
  end

  # @param [Symbol] size
  # @return [Float]
  def height_for_size(size = :medium)
    a = self.hw_ratio
    case size
    when :thumb
      a < 0.6 ? 213.0 * height.to_f / width.to_f : 160
    when :medium
      a < 1 ? a * 640 : 640
    when :big
      a < 1 ? a * 1600 : 1600
    when :original
      height
    else
      nil
    end
  end

  #  def filename(layout_section_type)
  #    'tmp/' + tempfile(layout_section_type).path.split('/').last
  #  end

  #  def tempfile(layout_section_type)
  #    tempfile = Tempfile.new([layout_section_type.to_s, '.jpg'], "#{Rails.root.to_s}/public/images/tmp", encoding: 'ASCII-8BIT' )
  #    tempfile.write(zoomed_image(layout_section_type).to_blob)
  #    tempfile
  #  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image]
  def self.cropped(params)
    image = Image.find(params[:id])
    img = Magick::Image.read(image.image_file.path(:original)).first
    begin
      # img.crop(x, y, width, height, true)
      cropped = img.crop( params[:x].to_i, params[:y].to_i, params[:width].to_i, params[:height].to_i, true)
    rescue RuntimeError
      cropped = img.crop(0,0, 1, 1)  # return a single pixel on error ! TODO: make/return an error image
    ensure
      img.destroy!
    end
    cropped
  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image]
  def self.resized(params)
    c = cropped(params)
    resized = c.resize(params[:new_width].to_i, params[:new_height].to_i) #.sharpen(0x1)
    c.destroy!
    resized
  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image, nil]
  def self.scaled_to_box(params)
    return nil if params[:box_width].to_f == 0 || params[:box_height].to_f == 0
    begin
      c = cropped(params)
      ratio = c.columns.to_f / c.rows.to_f
      box_ratio = params[:box_width].to_f / params[:box_height].to_f
      # TODO: special considerations for 1:1?

      if box_ratio > 1
        if ratio > 1 # wide into wide
          scaled = c.resize(
            params[:box_width].to_i,
            (params[:box_height].to_f / ratio * box_ratio).to_i
          ) #.sharpen(0x1)
        else # tall into wide
          scaled = c.resize(
            (params[:box_width ].to_f * ratio / box_ratio).to_i,
            params[:box_height].to_i
          ) #.sharpen(0x1)
        end
      else # <
        if ratio > 1 # wide into tall
          scaled = c.resize(
            params[:box_width].to_i,
            (params[:box_height].to_f / ratio * box_ratio).to_i
          ) #.sharpen(0x1)
        else # tall into tall # TODO: or 1:1?!
          scaled = c.resize(
            (params[:box_width].to_f * ratio / box_ratio ).to_i,
            (params[:box_height].to_f ).to_i
          ) #.sharpen(0x1)
        end
      end
      c.destroy!
    rescue Magick::ImageMagickError
      nil
    end
    scaled
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.scaled_to_box_blob(params)
    self.to_blob!(scaled_to_box(params))
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.resized_blob(params)
    self.to_blob!(resized(params))
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.cropped_blob(params)
    self.to_blob!(cropped(params))
  end

  # @param used_on [String] required, a depictable base class name like  `Otu`, `Content`, or `CollectionObject`
  # @return [Scope]
  #   the max 10 most recently used images, as `used_on`
  def self.used_recently(user_id, project_id, used_on = '')
    i = arel_table
    d = Depiction.arel_table

    # i is a select manager
    j = d.project(d['image_id'], d['updated_at'], d['depiction_object_type']).from(d)
      .where(d['updated_at'].gt( 1.week.ago ))
      .where(d['updated_by_id'].eq(user_id))
      .where(d['project_id'].eq(project_id))
      .order(d['updated_at'].desc)

    z = j.as('recent_i')

    k = Arel::Nodes::InnerJoin.new(z, Arel::Nodes::On.new(
      z['image_id'].eq(i['id']).and(z['depiction_object_type'].eq(used_on))
    ))

    joins(k).distinct.pluck(:id)
  end

  # @params target [String] required, one of nil, `AssertedDistribution`, `Content`, `BiologicalAssociation`, 'TaxonDetermination'
  # @return [Hash] images optimized for user selection
  def self.select_optimized(user_id, project_id, target = nil)
    r = used_recently(user_id, project_id, target)
    h = {
      quick: [],
      pinboard: Image.pinned_by(user_id).where(project_id:).to_a,
      recent: []
    }

    if target && !r.empty?
      h[:recent] = (
        Image.where('"images"."id" IN (?)', r.first(5) ).to_a +
        Image.where(project_id:, created_by_id: user_id, created_at: 3.hours.ago..Time.now)
        .order('updated_at DESC')
        .limit(3).to_a
      ).uniq.sort{|a,b| a.updated_at <=> b.updated_at}

      h[:quick] = (
        Image.pinned_by(user_id).pinboard_inserted.where(project_id:).to_a +
        Image.where('"images"."id" IN (?)', r.first(4) ).to_a)
        .uniq.sort{|a,b| a.updated_at <=> b.updated_at}
    else
      h[:recent] = Image.where(project_id:).order('updated_at DESC').limit(10).to_a
      h[:quick] = Image.pinned_by(user_id).pinboard_inserted.where(pinboard_items: {project_id:}).order('updated_at DESC')
    end

    h
  end

  protected

  # @return [Integer, Nil]
  def extract_tw_attributes
    # NOTE: assumes content type is an image.
    tempfile = image_file.queued_for_write[:original]
    if tempfile
      self.user_file_name = tempfile.original_filename
      geometry = Paperclip::Geometry.from_file(tempfile)
      self.width = geometry.width.to_i
      self.height = geometry.height.to_i
    end
  end

  private

  # Converts image to blob and releases memory of img (image cannot be used afterwards)
  # @param [Magick::Image] img
  # @return [String] a JPG representation of the image
  #   !! Always converts to .jpg, this may need abstraction later
  #   Returns an empty string if no image
  def self.to_blob!(img)
    return '' if img.nil?
    img.format = 'jpg'
    blob = img.to_blob
    img.destroy!
    blob
  end

  def image_dimensions_too_short
    return unless original = image_file.queued_for_write[:original]

    dimensions = Paperclip::Geometry.from_file(original)

    errors.add(:image_file, 'width must be at least 16 pixels') if dimensions.width < 16
    errors.add(:image_file, 'height must be at least 16 pixels') if dimensions.height < 16
  rescue
    errors.add(:image_file, 'unable to extract image dimensions')
  end

end

#image_file_content_typeString

Added by paperclip; the MIME (must be image) and file type (e.g. image/png).

Returns:

  • (String)


49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
# File 'app/models/image.rb', line 49

class Image < ApplicationRecord
  include Housekeeping
  include Shared::Identifiers
  include Shared::Notes
  include Shared::Tags
  include Shared::ProtocolRelationships
  include Shared::Citations
  include Shared::Attributions
  include Shared::IsData
  include SoftValidation

  include Image::Sled

  attr_accessor :rotate

  # ANY non-blank? value here will attempt to also create a depiction 
  # for the Image, linking it to a CollectionObject
  attr_accessor :filename_depicts_object

  MISSING_IMAGE_PATH = '/public/images/missing.jpg'.freeze

  GRAPH_ENTRY_POINTS = [:depictions]

  DEFAULT_SIZES = {
    thumb: { width: 100, height: 100 },
    medium: { width: 300, height: 300 }
  }.freeze

  has_one :sled_image, dependent: :destroy, inverse_of: :image

  has_many :depictions, inverse_of: :image, dependent: :restrict_with_error

  has_many :collection_objects, through: :depictions, source: :depiction_object, source_type: 'CollectionObject'
  has_many :otus, through: :depictions, source: :depiction_object, source_type: 'Otu'
  has_many :taxon_names, through: :otus

  after_validation :stub_depiction, if: Proc.new {|n| !n.filename_depicts_object.blank?}
  before_save :extract_tw_attributes

  # also using https://github.com/teeparham/paperclip-meta
  has_attached_file :image_file,
    styles: {
      thumb: [ "#{DEFAULT_SIZES[:thumb][:width]}x#{DEFAULT_SIZES[:thumb][:height]}>", :png ] ,
      medium: [ "#{DEFAULT_SIZES[:medium][:width]}x#{DEFAULT_SIZES[:medium][:height]}>", :jpg ] },
  default_url: MISSING_IMAGE_PATH,
  filename_cleaner: Utilities::CleanseFilename,
  processors: [:rotator]

  #:restricted_characters => /[^A-Za-z0-9\.]/,
  validates_attachment_content_type :image_file, content_type: /\Aimage\/.*\Z/
  validates_attachment_presence :image_file
  validate :image_dimensions_too_short

  validates_uniqueness_of :image_file_fingerprint, scope: :project_id

  accepts_nested_attributes_for :sled_image, allow_destroy: true

  accepts_nested_attributes_for :depictions, allow_destroy: false

  # This is bad and you should feel bad if your digitization workflow uses it.
  def stub_depiction
    identifier = File.basename(
      image_file.queued_for_write[:original].original_filename,
      '.*'
    )

    co = CollectionObject.joins(:identifiers).where(
      identifiers: {cached: identifier},
      project_id: Current.project_id,
    ).first

    if co.nil?
      errors.add(:base, 'filename does not match any known CollectionObject identifier')
      return
    end

    depictions.build(
      depiction_object_id: co.id,
      depiction_object_type: 'CollectionObject',
      by: Current.user_id,
      project_id: Current.project_id
    )
  end

  # Replaces Image.create!
  def self.deduplicate_create(image_params)
    image = Image.new(image_params)

    if i = Image.where(project_id: Current.project_id, image_file_fingerprint: image.image_file_fingerprint).first
      return i
    else
      return image
    end
  end

  def sqed_depiction
    depictions.joins(:sqed_depiction).first&.sqed_depiction
  end

  # TODO:
  # Deprecated, once images are de-duplicated
  #   this will be removed
  # @return [Boolean]
  def has_duplicate?
    Image.where(image_file_fingerprint: self.image_file_fingerprint).count > 1
  end

  # TODO:
  # Deprecated, once images are de-duplicated
  #   this will be removed. No duplicate images can now be created
  # @return [Array]
  def duplicate_images
    Image.where(image_file_fingerprint: self.image_file_fingerprint).not_self(self).to_a
  end

  # @return [Hash]
  # returns a hash of EXIF data if present, empty hash if not.do
  # EXIF data tags/specifications -  http://web.archive.org/web/20131018091152/http://exif.org/Exif2-2.PDF
  def exif
    ret_val = {} # return value

    unless self.new_record? # only process if record exists
      tmp     = `identify -format "%[EXIF:*]" #{self.image_file.url}` # returns a string (exif:tag=value\n)
      # following removes the exif, spits and recombines string as a hash
      ret_val = tmp.split("\n").collect { |b| b.gsub('exif:', '').split('=') }
        .inject({}) { |hsh, c| hsh.merge(c[0] => c[1]) }
      # might be able to tmp.split("\n").collect { |b|
      # b.gsub("exif:", "").split("=")
      # }.inject(ret_val) { |hsh, c|
      #   hsh.merge(c[0] => c[1])
      # }
    end

    ret_val # return
  end

  # TODO: move to /lib
  # @return [Nil]
  #  currently handling this client side
  def gps_data
    # if there is EXIF data, pulls out geographic coordinates & returns hash of lat/long in decimal degrees
    # (5 digits after decimal point if available)
    # EXIF gps information is in http://web.archive.org/web/20131018091152/http://exif.org/Exif2-2.PDF section 4.6.6
    # note that cameras follow specifications, but EXIF data can be edited manually and may not follow specifications.

    # check if gps data is in d m s (could be edited manually)
    #   => format dd/1,mm/1,ss/1 or dd/1,mmmm/100,0/1 or 40/1, 5/1, 314437/10000
    # N = +
    # S = -
    # E = +
    # W = -
    # Altitude should be based on reference of sea level
    # GPSAltitudeRef is 0 for above sea level, and 1 for below sea level

    # From discussion with Jim -
    # create a utility library called "GeoConvert" and define single method
    # that will convert from degrees min sec to decimal degree
    # - maybe 2 versions? - one returns string, other decimal?
  end

  # Returns the true, unscaled height/width ratio
  # @return [Float]
  def hw_ratio
    raise if height.nil? || width.nil? # if they are something has gone badly wrong
    return (height.to_f / width.to_f)
  end

  # rubocop:disable Style/StringHashKeys
  # used in ImageHelper#image_thumb_tag
  # asthetic scaling of very narrow images in thumbnails
  # @return [Hash]
  def thumb_scaler
    a = self.hw_ratio
    if a < 0.6
      { 'width' => 200, 'height' => 200 * a}
    else
      {}
    end
  end
  # rubocop:enable Style/StringHashKeys

  # the scale factor is typically the same except in a few cases where we skew small thumbs
  # @param [Symbol] size
  # @return [Float]
  def width_scale_for_size(size = :medium)
    (width_for_size(size).to_f / width.to_f)
  end

  # @param [Symbol] size
  # @return [Float]
  def height_scale_for_size(size = :medium)
    height_for_size(size).to_f / height.to_f
  end

  # @param [Symbol] size
  # @return [Float]
  def width_for_size(size = :medium)
    a = self.hw_ratio
    case size
    when :thumb
      a < 0.6 ? 200.0 : ((width.to_f / height.to_f ) * 160.0)
    when :medium
      a < 1 ? 640.0 : 640.0 / a
    when :big
      a < 1 ? 1600.0 : 1600.0 / a
    when :original
      width
    else
      nil
    end
  end

  # @param [Symbol] size
  # @return [Float]
  def height_for_size(size = :medium)
    a = self.hw_ratio
    case size
    when :thumb
      a < 0.6 ? 213.0 * height.to_f / width.to_f : 160
    when :medium
      a < 1 ? a * 640 : 640
    when :big
      a < 1 ? a * 1600 : 1600
    when :original
      height
    else
      nil
    end
  end

  #  def filename(layout_section_type)
  #    'tmp/' + tempfile(layout_section_type).path.split('/').last
  #  end

  #  def tempfile(layout_section_type)
  #    tempfile = Tempfile.new([layout_section_type.to_s, '.jpg'], "#{Rails.root.to_s}/public/images/tmp", encoding: 'ASCII-8BIT' )
  #    tempfile.write(zoomed_image(layout_section_type).to_blob)
  #    tempfile
  #  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image]
  def self.cropped(params)
    image = Image.find(params[:id])
    img = Magick::Image.read(image.image_file.path(:original)).first
    begin
      # img.crop(x, y, width, height, true)
      cropped = img.crop( params[:x].to_i, params[:y].to_i, params[:width].to_i, params[:height].to_i, true)
    rescue RuntimeError
      cropped = img.crop(0,0, 1, 1)  # return a single pixel on error ! TODO: make/return an error image
    ensure
      img.destroy!
    end
    cropped
  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image]
  def self.resized(params)
    c = cropped(params)
    resized = c.resize(params[:new_width].to_i, params[:new_height].to_i) #.sharpen(0x1)
    c.destroy!
    resized
  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image, nil]
  def self.scaled_to_box(params)
    return nil if params[:box_width].to_f == 0 || params[:box_height].to_f == 0
    begin
      c = cropped(params)
      ratio = c.columns.to_f / c.rows.to_f
      box_ratio = params[:box_width].to_f / params[:box_height].to_f
      # TODO: special considerations for 1:1?

      if box_ratio > 1
        if ratio > 1 # wide into wide
          scaled = c.resize(
            params[:box_width].to_i,
            (params[:box_height].to_f / ratio * box_ratio).to_i
          ) #.sharpen(0x1)
        else # tall into wide
          scaled = c.resize(
            (params[:box_width ].to_f * ratio / box_ratio).to_i,
            params[:box_height].to_i
          ) #.sharpen(0x1)
        end
      else # <
        if ratio > 1 # wide into tall
          scaled = c.resize(
            params[:box_width].to_i,
            (params[:box_height].to_f / ratio * box_ratio).to_i
          ) #.sharpen(0x1)
        else # tall into tall # TODO: or 1:1?!
          scaled = c.resize(
            (params[:box_width].to_f * ratio / box_ratio ).to_i,
            (params[:box_height].to_f ).to_i
          ) #.sharpen(0x1)
        end
      end
      c.destroy!
    rescue Magick::ImageMagickError
      nil
    end
    scaled
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.scaled_to_box_blob(params)
    self.to_blob!(scaled_to_box(params))
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.resized_blob(params)
    self.to_blob!(resized(params))
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.cropped_blob(params)
    self.to_blob!(cropped(params))
  end

  # @param used_on [String] required, a depictable base class name like  `Otu`, `Content`, or `CollectionObject`
  # @return [Scope]
  #   the max 10 most recently used images, as `used_on`
  def self.used_recently(user_id, project_id, used_on = '')
    i = arel_table
    d = Depiction.arel_table

    # i is a select manager
    j = d.project(d['image_id'], d['updated_at'], d['depiction_object_type']).from(d)
      .where(d['updated_at'].gt( 1.week.ago ))
      .where(d['updated_by_id'].eq(user_id))
      .where(d['project_id'].eq(project_id))
      .order(d['updated_at'].desc)

    z = j.as('recent_i')

    k = Arel::Nodes::InnerJoin.new(z, Arel::Nodes::On.new(
      z['image_id'].eq(i['id']).and(z['depiction_object_type'].eq(used_on))
    ))

    joins(k).distinct.pluck(:id)
  end

  # @params target [String] required, one of nil, `AssertedDistribution`, `Content`, `BiologicalAssociation`, 'TaxonDetermination'
  # @return [Hash] images optimized for user selection
  def self.select_optimized(user_id, project_id, target = nil)
    r = used_recently(user_id, project_id, target)
    h = {
      quick: [],
      pinboard: Image.pinned_by(user_id).where(project_id:).to_a,
      recent: []
    }

    if target && !r.empty?
      h[:recent] = (
        Image.where('"images"."id" IN (?)', r.first(5) ).to_a +
        Image.where(project_id:, created_by_id: user_id, created_at: 3.hours.ago..Time.now)
        .order('updated_at DESC')
        .limit(3).to_a
      ).uniq.sort{|a,b| a.updated_at <=> b.updated_at}

      h[:quick] = (
        Image.pinned_by(user_id).pinboard_inserted.where(project_id:).to_a +
        Image.where('"images"."id" IN (?)', r.first(4) ).to_a)
        .uniq.sort{|a,b| a.updated_at <=> b.updated_at}
    else
      h[:recent] = Image.where(project_id:).order('updated_at DESC').limit(10).to_a
      h[:quick] = Image.pinned_by(user_id).pinboard_inserted.where(pinboard_items: {project_id:}).order('updated_at DESC')
    end

    h
  end

  protected

  # @return [Integer, Nil]
  def extract_tw_attributes
    # NOTE: assumes content type is an image.
    tempfile = image_file.queued_for_write[:original]
    if tempfile
      self.user_file_name = tempfile.original_filename
      geometry = Paperclip::Geometry.from_file(tempfile)
      self.width = geometry.width.to_i
      self.height = geometry.height.to_i
    end
  end

  private

  # Converts image to blob and releases memory of img (image cannot be used afterwards)
  # @param [Magick::Image] img
  # @return [String] a JPG representation of the image
  #   !! Always converts to .jpg, this may need abstraction later
  #   Returns an empty string if no image
  def self.to_blob!(img)
    return '' if img.nil?
    img.format = 'jpg'
    blob = img.to_blob
    img.destroy!
    blob
  end

  def image_dimensions_too_short
    return unless original = image_file.queued_for_write[:original]

    dimensions = Paperclip::Geometry.from_file(original)

    errors.add(:image_file, 'width must be at least 16 pixels') if dimensions.width < 16
    errors.add(:image_file, 'height must be at least 16 pixels') if dimensions.height < 16
  rescue
    errors.add(:image_file, 'unable to extract image dimensions')
  end

end

#image_file_file_nameString

Added by paperclip; the filename after processing to remove special characters.

Returns:

  • (String)


49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
# File 'app/models/image.rb', line 49

class Image < ApplicationRecord
  include Housekeeping
  include Shared::Identifiers
  include Shared::Notes
  include Shared::Tags
  include Shared::ProtocolRelationships
  include Shared::Citations
  include Shared::Attributions
  include Shared::IsData
  include SoftValidation

  include Image::Sled

  attr_accessor :rotate

  # ANY non-blank? value here will attempt to also create a depiction 
  # for the Image, linking it to a CollectionObject
  attr_accessor :filename_depicts_object

  MISSING_IMAGE_PATH = '/public/images/missing.jpg'.freeze

  GRAPH_ENTRY_POINTS = [:depictions]

  DEFAULT_SIZES = {
    thumb: { width: 100, height: 100 },
    medium: { width: 300, height: 300 }
  }.freeze

  has_one :sled_image, dependent: :destroy, inverse_of: :image

  has_many :depictions, inverse_of: :image, dependent: :restrict_with_error

  has_many :collection_objects, through: :depictions, source: :depiction_object, source_type: 'CollectionObject'
  has_many :otus, through: :depictions, source: :depiction_object, source_type: 'Otu'
  has_many :taxon_names, through: :otus

  after_validation :stub_depiction, if: Proc.new {|n| !n.filename_depicts_object.blank?}
  before_save :extract_tw_attributes

  # also using https://github.com/teeparham/paperclip-meta
  has_attached_file :image_file,
    styles: {
      thumb: [ "#{DEFAULT_SIZES[:thumb][:width]}x#{DEFAULT_SIZES[:thumb][:height]}>", :png ] ,
      medium: [ "#{DEFAULT_SIZES[:medium][:width]}x#{DEFAULT_SIZES[:medium][:height]}>", :jpg ] },
  default_url: MISSING_IMAGE_PATH,
  filename_cleaner: Utilities::CleanseFilename,
  processors: [:rotator]

  #:restricted_characters => /[^A-Za-z0-9\.]/,
  validates_attachment_content_type :image_file, content_type: /\Aimage\/.*\Z/
  validates_attachment_presence :image_file
  validate :image_dimensions_too_short

  validates_uniqueness_of :image_file_fingerprint, scope: :project_id

  accepts_nested_attributes_for :sled_image, allow_destroy: true

  accepts_nested_attributes_for :depictions, allow_destroy: false

  # This is bad and you should feel bad if your digitization workflow uses it.
  def stub_depiction
    identifier = File.basename(
      image_file.queued_for_write[:original].original_filename,
      '.*'
    )

    co = CollectionObject.joins(:identifiers).where(
      identifiers: {cached: identifier},
      project_id: Current.project_id,
    ).first

    if co.nil?
      errors.add(:base, 'filename does not match any known CollectionObject identifier')
      return
    end

    depictions.build(
      depiction_object_id: co.id,
      depiction_object_type: 'CollectionObject',
      by: Current.user_id,
      project_id: Current.project_id
    )
  end

  # Replaces Image.create!
  def self.deduplicate_create(image_params)
    image = Image.new(image_params)

    if i = Image.where(project_id: Current.project_id, image_file_fingerprint: image.image_file_fingerprint).first
      return i
    else
      return image
    end
  end

  def sqed_depiction
    depictions.joins(:sqed_depiction).first&.sqed_depiction
  end

  # TODO:
  # Deprecated, once images are de-duplicated
  #   this will be removed
  # @return [Boolean]
  def has_duplicate?
    Image.where(image_file_fingerprint: self.image_file_fingerprint).count > 1
  end

  # TODO:
  # Deprecated, once images are de-duplicated
  #   this will be removed. No duplicate images can now be created
  # @return [Array]
  def duplicate_images
    Image.where(image_file_fingerprint: self.image_file_fingerprint).not_self(self).to_a
  end

  # @return [Hash]
  # returns a hash of EXIF data if present, empty hash if not.do
  # EXIF data tags/specifications -  http://web.archive.org/web/20131018091152/http://exif.org/Exif2-2.PDF
  def exif
    ret_val = {} # return value

    unless self.new_record? # only process if record exists
      tmp     = `identify -format "%[EXIF:*]" #{self.image_file.url}` # returns a string (exif:tag=value\n)
      # following removes the exif, spits and recombines string as a hash
      ret_val = tmp.split("\n").collect { |b| b.gsub('exif:', '').split('=') }
        .inject({}) { |hsh, c| hsh.merge(c[0] => c[1]) }
      # might be able to tmp.split("\n").collect { |b|
      # b.gsub("exif:", "").split("=")
      # }.inject(ret_val) { |hsh, c|
      #   hsh.merge(c[0] => c[1])
      # }
    end

    ret_val # return
  end

  # TODO: move to /lib
  # @return [Nil]
  #  currently handling this client side
  def gps_data
    # if there is EXIF data, pulls out geographic coordinates & returns hash of lat/long in decimal degrees
    # (5 digits after decimal point if available)
    # EXIF gps information is in http://web.archive.org/web/20131018091152/http://exif.org/Exif2-2.PDF section 4.6.6
    # note that cameras follow specifications, but EXIF data can be edited manually and may not follow specifications.

    # check if gps data is in d m s (could be edited manually)
    #   => format dd/1,mm/1,ss/1 or dd/1,mmmm/100,0/1 or 40/1, 5/1, 314437/10000
    # N = +
    # S = -
    # E = +
    # W = -
    # Altitude should be based on reference of sea level
    # GPSAltitudeRef is 0 for above sea level, and 1 for below sea level

    # From discussion with Jim -
    # create a utility library called "GeoConvert" and define single method
    # that will convert from degrees min sec to decimal degree
    # - maybe 2 versions? - one returns string, other decimal?
  end

  # Returns the true, unscaled height/width ratio
  # @return [Float]
  def hw_ratio
    raise if height.nil? || width.nil? # if they are something has gone badly wrong
    return (height.to_f / width.to_f)
  end

  # rubocop:disable Style/StringHashKeys
  # used in ImageHelper#image_thumb_tag
  # asthetic scaling of very narrow images in thumbnails
  # @return [Hash]
  def thumb_scaler
    a = self.hw_ratio
    if a < 0.6
      { 'width' => 200, 'height' => 200 * a}
    else
      {}
    end
  end
  # rubocop:enable Style/StringHashKeys

  # the scale factor is typically the same except in a few cases where we skew small thumbs
  # @param [Symbol] size
  # @return [Float]
  def width_scale_for_size(size = :medium)
    (width_for_size(size).to_f / width.to_f)
  end

  # @param [Symbol] size
  # @return [Float]
  def height_scale_for_size(size = :medium)
    height_for_size(size).to_f / height.to_f
  end

  # @param [Symbol] size
  # @return [Float]
  def width_for_size(size = :medium)
    a = self.hw_ratio
    case size
    when :thumb
      a < 0.6 ? 200.0 : ((width.to_f / height.to_f ) * 160.0)
    when :medium
      a < 1 ? 640.0 : 640.0 / a
    when :big
      a < 1 ? 1600.0 : 1600.0 / a
    when :original
      width
    else
      nil
    end
  end

  # @param [Symbol] size
  # @return [Float]
  def height_for_size(size = :medium)
    a = self.hw_ratio
    case size
    when :thumb
      a < 0.6 ? 213.0 * height.to_f / width.to_f : 160
    when :medium
      a < 1 ? a * 640 : 640
    when :big
      a < 1 ? a * 1600 : 1600
    when :original
      height
    else
      nil
    end
  end

  #  def filename(layout_section_type)
  #    'tmp/' + tempfile(layout_section_type).path.split('/').last
  #  end

  #  def tempfile(layout_section_type)
  #    tempfile = Tempfile.new([layout_section_type.to_s, '.jpg'], "#{Rails.root.to_s}/public/images/tmp", encoding: 'ASCII-8BIT' )
  #    tempfile.write(zoomed_image(layout_section_type).to_blob)
  #    tempfile
  #  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image]
  def self.cropped(params)
    image = Image.find(params[:id])
    img = Magick::Image.read(image.image_file.path(:original)).first
    begin
      # img.crop(x, y, width, height, true)
      cropped = img.crop( params[:x].to_i, params[:y].to_i, params[:width].to_i, params[:height].to_i, true)
    rescue RuntimeError
      cropped = img.crop(0,0, 1, 1)  # return a single pixel on error ! TODO: make/return an error image
    ensure
      img.destroy!
    end
    cropped
  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image]
  def self.resized(params)
    c = cropped(params)
    resized = c.resize(params[:new_width].to_i, params[:new_height].to_i) #.sharpen(0x1)
    c.destroy!
    resized
  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image, nil]
  def self.scaled_to_box(params)
    return nil if params[:box_width].to_f == 0 || params[:box_height].to_f == 0
    begin
      c = cropped(params)
      ratio = c.columns.to_f / c.rows.to_f
      box_ratio = params[:box_width].to_f / params[:box_height].to_f
      # TODO: special considerations for 1:1?

      if box_ratio > 1
        if ratio > 1 # wide into wide
          scaled = c.resize(
            params[:box_width].to_i,
            (params[:box_height].to_f / ratio * box_ratio).to_i
          ) #.sharpen(0x1)
        else # tall into wide
          scaled = c.resize(
            (params[:box_width ].to_f * ratio / box_ratio).to_i,
            params[:box_height].to_i
          ) #.sharpen(0x1)
        end
      else # <
        if ratio > 1 # wide into tall
          scaled = c.resize(
            params[:box_width].to_i,
            (params[:box_height].to_f / ratio * box_ratio).to_i
          ) #.sharpen(0x1)
        else # tall into tall # TODO: or 1:1?!
          scaled = c.resize(
            (params[:box_width].to_f * ratio / box_ratio ).to_i,
            (params[:box_height].to_f ).to_i
          ) #.sharpen(0x1)
        end
      end
      c.destroy!
    rescue Magick::ImageMagickError
      nil
    end
    scaled
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.scaled_to_box_blob(params)
    self.to_blob!(scaled_to_box(params))
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.resized_blob(params)
    self.to_blob!(resized(params))
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.cropped_blob(params)
    self.to_blob!(cropped(params))
  end

  # @param used_on [String] required, a depictable base class name like  `Otu`, `Content`, or `CollectionObject`
  # @return [Scope]
  #   the max 10 most recently used images, as `used_on`
  def self.used_recently(user_id, project_id, used_on = '')
    i = arel_table
    d = Depiction.arel_table

    # i is a select manager
    j = d.project(d['image_id'], d['updated_at'], d['depiction_object_type']).from(d)
      .where(d['updated_at'].gt( 1.week.ago ))
      .where(d['updated_by_id'].eq(user_id))
      .where(d['project_id'].eq(project_id))
      .order(d['updated_at'].desc)

    z = j.as('recent_i')

    k = Arel::Nodes::InnerJoin.new(z, Arel::Nodes::On.new(
      z['image_id'].eq(i['id']).and(z['depiction_object_type'].eq(used_on))
    ))

    joins(k).distinct.pluck(:id)
  end

  # @params target [String] required, one of nil, `AssertedDistribution`, `Content`, `BiologicalAssociation`, 'TaxonDetermination'
  # @return [Hash] images optimized for user selection
  def self.select_optimized(user_id, project_id, target = nil)
    r = used_recently(user_id, project_id, target)
    h = {
      quick: [],
      pinboard: Image.pinned_by(user_id).where(project_id:).to_a,
      recent: []
    }

    if target && !r.empty?
      h[:recent] = (
        Image.where('"images"."id" IN (?)', r.first(5) ).to_a +
        Image.where(project_id:, created_by_id: user_id, created_at: 3.hours.ago..Time.now)
        .order('updated_at DESC')
        .limit(3).to_a
      ).uniq.sort{|a,b| a.updated_at <=> b.updated_at}

      h[:quick] = (
        Image.pinned_by(user_id).pinboard_inserted.where(project_id:).to_a +
        Image.where('"images"."id" IN (?)', r.first(4) ).to_a)
        .uniq.sort{|a,b| a.updated_at <=> b.updated_at}
    else
      h[:recent] = Image.where(project_id:).order('updated_at DESC').limit(10).to_a
      h[:quick] = Image.pinned_by(user_id).pinboard_inserted.where(pinboard_items: {project_id:}).order('updated_at DESC')
    end

    h
  end

  protected

  # @return [Integer, Nil]
  def extract_tw_attributes
    # NOTE: assumes content type is an image.
    tempfile = image_file.queued_for_write[:original]
    if tempfile
      self.user_file_name = tempfile.original_filename
      geometry = Paperclip::Geometry.from_file(tempfile)
      self.width = geometry.width.to_i
      self.height = geometry.height.to_i
    end
  end

  private

  # Converts image to blob and releases memory of img (image cannot be used afterwards)
  # @param [Magick::Image] img
  # @return [String] a JPG representation of the image
  #   !! Always converts to .jpg, this may need abstraction later
  #   Returns an empty string if no image
  def self.to_blob!(img)
    return '' if img.nil?
    img.format = 'jpg'
    blob = img.to_blob
    img.destroy!
    blob
  end

  def image_dimensions_too_short
    return unless original = image_file.queued_for_write[:original]

    dimensions = Paperclip::Geometry.from_file(original)

    errors.add(:image_file, 'width must be at least 16 pixels') if dimensions.width < 16
    errors.add(:image_file, 'height must be at least 16 pixels') if dimensions.height < 16
  rescue
    errors.add(:image_file, 'unable to extract image dimensions')
  end

end

#image_file_file_sizeInteger

Added by paperclip

Returns:

  • (Integer)


49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
# File 'app/models/image.rb', line 49

class Image < ApplicationRecord
  include Housekeeping
  include Shared::Identifiers
  include Shared::Notes
  include Shared::Tags
  include Shared::ProtocolRelationships
  include Shared::Citations
  include Shared::Attributions
  include Shared::IsData
  include SoftValidation

  include Image::Sled

  attr_accessor :rotate

  # ANY non-blank? value here will attempt to also create a depiction 
  # for the Image, linking it to a CollectionObject
  attr_accessor :filename_depicts_object

  MISSING_IMAGE_PATH = '/public/images/missing.jpg'.freeze

  GRAPH_ENTRY_POINTS = [:depictions]

  DEFAULT_SIZES = {
    thumb: { width: 100, height: 100 },
    medium: { width: 300, height: 300 }
  }.freeze

  has_one :sled_image, dependent: :destroy, inverse_of: :image

  has_many :depictions, inverse_of: :image, dependent: :restrict_with_error

  has_many :collection_objects, through: :depictions, source: :depiction_object, source_type: 'CollectionObject'
  has_many :otus, through: :depictions, source: :depiction_object, source_type: 'Otu'
  has_many :taxon_names, through: :otus

  after_validation :stub_depiction, if: Proc.new {|n| !n.filename_depicts_object.blank?}
  before_save :extract_tw_attributes

  # also using https://github.com/teeparham/paperclip-meta
  has_attached_file :image_file,
    styles: {
      thumb: [ "#{DEFAULT_SIZES[:thumb][:width]}x#{DEFAULT_SIZES[:thumb][:height]}>", :png ] ,
      medium: [ "#{DEFAULT_SIZES[:medium][:width]}x#{DEFAULT_SIZES[:medium][:height]}>", :jpg ] },
  default_url: MISSING_IMAGE_PATH,
  filename_cleaner: Utilities::CleanseFilename,
  processors: [:rotator]

  #:restricted_characters => /[^A-Za-z0-9\.]/,
  validates_attachment_content_type :image_file, content_type: /\Aimage\/.*\Z/
  validates_attachment_presence :image_file
  validate :image_dimensions_too_short

  validates_uniqueness_of :image_file_fingerprint, scope: :project_id

  accepts_nested_attributes_for :sled_image, allow_destroy: true

  accepts_nested_attributes_for :depictions, allow_destroy: false

  # This is bad and you should feel bad if your digitization workflow uses it.
  def stub_depiction
    identifier = File.basename(
      image_file.queued_for_write[:original].original_filename,
      '.*'
    )

    co = CollectionObject.joins(:identifiers).where(
      identifiers: {cached: identifier},
      project_id: Current.project_id,
    ).first

    if co.nil?
      errors.add(:base, 'filename does not match any known CollectionObject identifier')
      return
    end

    depictions.build(
      depiction_object_id: co.id,
      depiction_object_type: 'CollectionObject',
      by: Current.user_id,
      project_id: Current.project_id
    )
  end

  # Replaces Image.create!
  def self.deduplicate_create(image_params)
    image = Image.new(image_params)

    if i = Image.where(project_id: Current.project_id, image_file_fingerprint: image.image_file_fingerprint).first
      return i
    else
      return image
    end
  end

  def sqed_depiction
    depictions.joins(:sqed_depiction).first&.sqed_depiction
  end

  # TODO:
  # Deprecated, once images are de-duplicated
  #   this will be removed
  # @return [Boolean]
  def has_duplicate?
    Image.where(image_file_fingerprint: self.image_file_fingerprint).count > 1
  end

  # TODO:
  # Deprecated, once images are de-duplicated
  #   this will be removed. No duplicate images can now be created
  # @return [Array]
  def duplicate_images
    Image.where(image_file_fingerprint: self.image_file_fingerprint).not_self(self).to_a
  end

  # @return [Hash]
  # returns a hash of EXIF data if present, empty hash if not.do
  # EXIF data tags/specifications -  http://web.archive.org/web/20131018091152/http://exif.org/Exif2-2.PDF
  def exif
    ret_val = {} # return value

    unless self.new_record? # only process if record exists
      tmp     = `identify -format "%[EXIF:*]" #{self.image_file.url}` # returns a string (exif:tag=value\n)
      # following removes the exif, spits and recombines string as a hash
      ret_val = tmp.split("\n").collect { |b| b.gsub('exif:', '').split('=') }
        .inject({}) { |hsh, c| hsh.merge(c[0] => c[1]) }
      # might be able to tmp.split("\n").collect { |b|
      # b.gsub("exif:", "").split("=")
      # }.inject(ret_val) { |hsh, c|
      #   hsh.merge(c[0] => c[1])
      # }
    end

    ret_val # return
  end

  # TODO: move to /lib
  # @return [Nil]
  #  currently handling this client side
  def gps_data
    # if there is EXIF data, pulls out geographic coordinates & returns hash of lat/long in decimal degrees
    # (5 digits after decimal point if available)
    # EXIF gps information is in http://web.archive.org/web/20131018091152/http://exif.org/Exif2-2.PDF section 4.6.6
    # note that cameras follow specifications, but EXIF data can be edited manually and may not follow specifications.

    # check if gps data is in d m s (could be edited manually)
    #   => format dd/1,mm/1,ss/1 or dd/1,mmmm/100,0/1 or 40/1, 5/1, 314437/10000
    # N = +
    # S = -
    # E = +
    # W = -
    # Altitude should be based on reference of sea level
    # GPSAltitudeRef is 0 for above sea level, and 1 for below sea level

    # From discussion with Jim -
    # create a utility library called "GeoConvert" and define single method
    # that will convert from degrees min sec to decimal degree
    # - maybe 2 versions? - one returns string, other decimal?
  end

  # Returns the true, unscaled height/width ratio
  # @return [Float]
  def hw_ratio
    raise if height.nil? || width.nil? # if they are something has gone badly wrong
    return (height.to_f / width.to_f)
  end

  # rubocop:disable Style/StringHashKeys
  # used in ImageHelper#image_thumb_tag
  # asthetic scaling of very narrow images in thumbnails
  # @return [Hash]
  def thumb_scaler
    a = self.hw_ratio
    if a < 0.6
      { 'width' => 200, 'height' => 200 * a}
    else
      {}
    end
  end
  # rubocop:enable Style/StringHashKeys

  # the scale factor is typically the same except in a few cases where we skew small thumbs
  # @param [Symbol] size
  # @return [Float]
  def width_scale_for_size(size = :medium)
    (width_for_size(size).to_f / width.to_f)
  end

  # @param [Symbol] size
  # @return [Float]
  def height_scale_for_size(size = :medium)
    height_for_size(size).to_f / height.to_f
  end

  # @param [Symbol] size
  # @return [Float]
  def width_for_size(size = :medium)
    a = self.hw_ratio
    case size
    when :thumb
      a < 0.6 ? 200.0 : ((width.to_f / height.to_f ) * 160.0)
    when :medium
      a < 1 ? 640.0 : 640.0 / a
    when :big
      a < 1 ? 1600.0 : 1600.0 / a
    when :original
      width
    else
      nil
    end
  end

  # @param [Symbol] size
  # @return [Float]
  def height_for_size(size = :medium)
    a = self.hw_ratio
    case size
    when :thumb
      a < 0.6 ? 213.0 * height.to_f / width.to_f : 160
    when :medium
      a < 1 ? a * 640 : 640
    when :big
      a < 1 ? a * 1600 : 1600
    when :original
      height
    else
      nil
    end
  end

  #  def filename(layout_section_type)
  #    'tmp/' + tempfile(layout_section_type).path.split('/').last
  #  end

  #  def tempfile(layout_section_type)
  #    tempfile = Tempfile.new([layout_section_type.to_s, '.jpg'], "#{Rails.root.to_s}/public/images/tmp", encoding: 'ASCII-8BIT' )
  #    tempfile.write(zoomed_image(layout_section_type).to_blob)
  #    tempfile
  #  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image]
  def self.cropped(params)
    image = Image.find(params[:id])
    img = Magick::Image.read(image.image_file.path(:original)).first
    begin
      # img.crop(x, y, width, height, true)
      cropped = img.crop( params[:x].to_i, params[:y].to_i, params[:width].to_i, params[:height].to_i, true)
    rescue RuntimeError
      cropped = img.crop(0,0, 1, 1)  # return a single pixel on error ! TODO: make/return an error image
    ensure
      img.destroy!
    end
    cropped
  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image]
  def self.resized(params)
    c = cropped(params)
    resized = c.resize(params[:new_width].to_i, params[:new_height].to_i) #.sharpen(0x1)
    c.destroy!
    resized
  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image, nil]
  def self.scaled_to_box(params)
    return nil if params[:box_width].to_f == 0 || params[:box_height].to_f == 0
    begin
      c = cropped(params)
      ratio = c.columns.to_f / c.rows.to_f
      box_ratio = params[:box_width].to_f / params[:box_height].to_f
      # TODO: special considerations for 1:1?

      if box_ratio > 1
        if ratio > 1 # wide into wide
          scaled = c.resize(
            params[:box_width].to_i,
            (params[:box_height].to_f / ratio * box_ratio).to_i
          ) #.sharpen(0x1)
        else # tall into wide
          scaled = c.resize(
            (params[:box_width ].to_f * ratio / box_ratio).to_i,
            params[:box_height].to_i
          ) #.sharpen(0x1)
        end
      else # <
        if ratio > 1 # wide into tall
          scaled = c.resize(
            params[:box_width].to_i,
            (params[:box_height].to_f / ratio * box_ratio).to_i
          ) #.sharpen(0x1)
        else # tall into tall # TODO: or 1:1?!
          scaled = c.resize(
            (params[:box_width].to_f * ratio / box_ratio ).to_i,
            (params[:box_height].to_f ).to_i
          ) #.sharpen(0x1)
        end
      end
      c.destroy!
    rescue Magick::ImageMagickError
      nil
    end
    scaled
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.scaled_to_box_blob(params)
    self.to_blob!(scaled_to_box(params))
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.resized_blob(params)
    self.to_blob!(resized(params))
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.cropped_blob(params)
    self.to_blob!(cropped(params))
  end

  # @param used_on [String] required, a depictable base class name like  `Otu`, `Content`, or `CollectionObject`
  # @return [Scope]
  #   the max 10 most recently used images, as `used_on`
  def self.used_recently(user_id, project_id, used_on = '')
    i = arel_table
    d = Depiction.arel_table

    # i is a select manager
    j = d.project(d['image_id'], d['updated_at'], d['depiction_object_type']).from(d)
      .where(d['updated_at'].gt( 1.week.ago ))
      .where(d['updated_by_id'].eq(user_id))
      .where(d['project_id'].eq(project_id))
      .order(d['updated_at'].desc)

    z = j.as('recent_i')

    k = Arel::Nodes::InnerJoin.new(z, Arel::Nodes::On.new(
      z['image_id'].eq(i['id']).and(z['depiction_object_type'].eq(used_on))
    ))

    joins(k).distinct.pluck(:id)
  end

  # @params target [String] required, one of nil, `AssertedDistribution`, `Content`, `BiologicalAssociation`, 'TaxonDetermination'
  # @return [Hash] images optimized for user selection
  def self.select_optimized(user_id, project_id, target = nil)
    r = used_recently(user_id, project_id, target)
    h = {
      quick: [],
      pinboard: Image.pinned_by(user_id).where(project_id:).to_a,
      recent: []
    }

    if target && !r.empty?
      h[:recent] = (
        Image.where('"images"."id" IN (?)', r.first(5) ).to_a +
        Image.where(project_id:, created_by_id: user_id, created_at: 3.hours.ago..Time.now)
        .order('updated_at DESC')
        .limit(3).to_a
      ).uniq.sort{|a,b| a.updated_at <=> b.updated_at}

      h[:quick] = (
        Image.pinned_by(user_id).pinboard_inserted.where(project_id:).to_a +
        Image.where('"images"."id" IN (?)', r.first(4) ).to_a)
        .uniq.sort{|a,b| a.updated_at <=> b.updated_at}
    else
      h[:recent] = Image.where(project_id:).order('updated_at DESC').limit(10).to_a
      h[:quick] = Image.pinned_by(user_id).pinboard_inserted.where(pinboard_items: {project_id:}).order('updated_at DESC')
    end

    h
  end

  protected

  # @return [Integer, Nil]
  def extract_tw_attributes
    # NOTE: assumes content type is an image.
    tempfile = image_file.queued_for_write[:original]
    if tempfile
      self.user_file_name = tempfile.original_filename
      geometry = Paperclip::Geometry.from_file(tempfile)
      self.width = geometry.width.to_i
      self.height = geometry.height.to_i
    end
  end

  private

  # Converts image to blob and releases memory of img (image cannot be used afterwards)
  # @param [Magick::Image] img
  # @return [String] a JPG representation of the image
  #   !! Always converts to .jpg, this may need abstraction later
  #   Returns an empty string if no image
  def self.to_blob!(img)
    return '' if img.nil?
    img.format = 'jpg'
    blob = img.to_blob
    img.destroy!
    blob
  end

  def image_dimensions_too_short
    return unless original = image_file.queued_for_write[:original]

    dimensions = Paperclip::Geometry.from_file(original)

    errors.add(:image_file, 'width must be at least 16 pixels') if dimensions.width < 16
    errors.add(:image_file, 'height must be at least 16 pixels') if dimensions.height < 16
  rescue
    errors.add(:image_file, 'unable to extract image dimensions')
  end

end

#image_file_fingerprintString

Added by paperclip; MD5 for the image file

Returns:

  • (String)


49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
# File 'app/models/image.rb', line 49

class Image < ApplicationRecord
  include Housekeeping
  include Shared::Identifiers
  include Shared::Notes
  include Shared::Tags
  include Shared::ProtocolRelationships
  include Shared::Citations
  include Shared::Attributions
  include Shared::IsData
  include SoftValidation

  include Image::Sled

  attr_accessor :rotate

  # ANY non-blank? value here will attempt to also create a depiction 
  # for the Image, linking it to a CollectionObject
  attr_accessor :filename_depicts_object

  MISSING_IMAGE_PATH = '/public/images/missing.jpg'.freeze

  GRAPH_ENTRY_POINTS = [:depictions]

  DEFAULT_SIZES = {
    thumb: { width: 100, height: 100 },
    medium: { width: 300, height: 300 }
  }.freeze

  has_one :sled_image, dependent: :destroy, inverse_of: :image

  has_many :depictions, inverse_of: :image, dependent: :restrict_with_error

  has_many :collection_objects, through: :depictions, source: :depiction_object, source_type: 'CollectionObject'
  has_many :otus, through: :depictions, source: :depiction_object, source_type: 'Otu'
  has_many :taxon_names, through: :otus

  after_validation :stub_depiction, if: Proc.new {|n| !n.filename_depicts_object.blank?}
  before_save :extract_tw_attributes

  # also using https://github.com/teeparham/paperclip-meta
  has_attached_file :image_file,
    styles: {
      thumb: [ "#{DEFAULT_SIZES[:thumb][:width]}x#{DEFAULT_SIZES[:thumb][:height]}>", :png ] ,
      medium: [ "#{DEFAULT_SIZES[:medium][:width]}x#{DEFAULT_SIZES[:medium][:height]}>", :jpg ] },
  default_url: MISSING_IMAGE_PATH,
  filename_cleaner: Utilities::CleanseFilename,
  processors: [:rotator]

  #:restricted_characters => /[^A-Za-z0-9\.]/,
  validates_attachment_content_type :image_file, content_type: /\Aimage\/.*\Z/
  validates_attachment_presence :image_file
  validate :image_dimensions_too_short

  validates_uniqueness_of :image_file_fingerprint, scope: :project_id

  accepts_nested_attributes_for :sled_image, allow_destroy: true

  accepts_nested_attributes_for :depictions, allow_destroy: false

  # This is bad and you should feel bad if your digitization workflow uses it.
  def stub_depiction
    identifier = File.basename(
      image_file.queued_for_write[:original].original_filename,
      '.*'
    )

    co = CollectionObject.joins(:identifiers).where(
      identifiers: {cached: identifier},
      project_id: Current.project_id,
    ).first

    if co.nil?
      errors.add(:base, 'filename does not match any known CollectionObject identifier')
      return
    end

    depictions.build(
      depiction_object_id: co.id,
      depiction_object_type: 'CollectionObject',
      by: Current.user_id,
      project_id: Current.project_id
    )
  end

  # Replaces Image.create!
  def self.deduplicate_create(image_params)
    image = Image.new(image_params)

    if i = Image.where(project_id: Current.project_id, image_file_fingerprint: image.image_file_fingerprint).first
      return i
    else
      return image
    end
  end

  def sqed_depiction
    depictions.joins(:sqed_depiction).first&.sqed_depiction
  end

  # TODO:
  # Deprecated, once images are de-duplicated
  #   this will be removed
  # @return [Boolean]
  def has_duplicate?
    Image.where(image_file_fingerprint: self.image_file_fingerprint).count > 1
  end

  # TODO:
  # Deprecated, once images are de-duplicated
  #   this will be removed. No duplicate images can now be created
  # @return [Array]
  def duplicate_images
    Image.where(image_file_fingerprint: self.image_file_fingerprint).not_self(self).to_a
  end

  # @return [Hash]
  # returns a hash of EXIF data if present, empty hash if not.do
  # EXIF data tags/specifications -  http://web.archive.org/web/20131018091152/http://exif.org/Exif2-2.PDF
  def exif
    ret_val = {} # return value

    unless self.new_record? # only process if record exists
      tmp     = `identify -format "%[EXIF:*]" #{self.image_file.url}` # returns a string (exif:tag=value\n)
      # following removes the exif, spits and recombines string as a hash
      ret_val = tmp.split("\n").collect { |b| b.gsub('exif:', '').split('=') }
        .inject({}) { |hsh, c| hsh.merge(c[0] => c[1]) }
      # might be able to tmp.split("\n").collect { |b|
      # b.gsub("exif:", "").split("=")
      # }.inject(ret_val) { |hsh, c|
      #   hsh.merge(c[0] => c[1])
      # }
    end

    ret_val # return
  end

  # TODO: move to /lib
  # @return [Nil]
  #  currently handling this client side
  def gps_data
    # if there is EXIF data, pulls out geographic coordinates & returns hash of lat/long in decimal degrees
    # (5 digits after decimal point if available)
    # EXIF gps information is in http://web.archive.org/web/20131018091152/http://exif.org/Exif2-2.PDF section 4.6.6
    # note that cameras follow specifications, but EXIF data can be edited manually and may not follow specifications.

    # check if gps data is in d m s (could be edited manually)
    #   => format dd/1,mm/1,ss/1 or dd/1,mmmm/100,0/1 or 40/1, 5/1, 314437/10000
    # N = +
    # S = -
    # E = +
    # W = -
    # Altitude should be based on reference of sea level
    # GPSAltitudeRef is 0 for above sea level, and 1 for below sea level

    # From discussion with Jim -
    # create a utility library called "GeoConvert" and define single method
    # that will convert from degrees min sec to decimal degree
    # - maybe 2 versions? - one returns string, other decimal?
  end

  # Returns the true, unscaled height/width ratio
  # @return [Float]
  def hw_ratio
    raise if height.nil? || width.nil? # if they are something has gone badly wrong
    return (height.to_f / width.to_f)
  end

  # rubocop:disable Style/StringHashKeys
  # used in ImageHelper#image_thumb_tag
  # asthetic scaling of very narrow images in thumbnails
  # @return [Hash]
  def thumb_scaler
    a = self.hw_ratio
    if a < 0.6
      { 'width' => 200, 'height' => 200 * a}
    else
      {}
    end
  end
  # rubocop:enable Style/StringHashKeys

  # the scale factor is typically the same except in a few cases where we skew small thumbs
  # @param [Symbol] size
  # @return [Float]
  def width_scale_for_size(size = :medium)
    (width_for_size(size).to_f / width.to_f)
  end

  # @param [Symbol] size
  # @return [Float]
  def height_scale_for_size(size = :medium)
    height_for_size(size).to_f / height.to_f
  end

  # @param [Symbol] size
  # @return [Float]
  def width_for_size(size = :medium)
    a = self.hw_ratio
    case size
    when :thumb
      a < 0.6 ? 200.0 : ((width.to_f / height.to_f ) * 160.0)
    when :medium
      a < 1 ? 640.0 : 640.0 / a
    when :big
      a < 1 ? 1600.0 : 1600.0 / a
    when :original
      width
    else
      nil
    end
  end

  # @param [Symbol] size
  # @return [Float]
  def height_for_size(size = :medium)
    a = self.hw_ratio
    case size
    when :thumb
      a < 0.6 ? 213.0 * height.to_f / width.to_f : 160
    when :medium
      a < 1 ? a * 640 : 640
    when :big
      a < 1 ? a * 1600 : 1600
    when :original
      height
    else
      nil
    end
  end

  #  def filename(layout_section_type)
  #    'tmp/' + tempfile(layout_section_type).path.split('/').last
  #  end

  #  def tempfile(layout_section_type)
  #    tempfile = Tempfile.new([layout_section_type.to_s, '.jpg'], "#{Rails.root.to_s}/public/images/tmp", encoding: 'ASCII-8BIT' )
  #    tempfile.write(zoomed_image(layout_section_type).to_blob)
  #    tempfile
  #  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image]
  def self.cropped(params)
    image = Image.find(params[:id])
    img = Magick::Image.read(image.image_file.path(:original)).first
    begin
      # img.crop(x, y, width, height, true)
      cropped = img.crop( params[:x].to_i, params[:y].to_i, params[:width].to_i, params[:height].to_i, true)
    rescue RuntimeError
      cropped = img.crop(0,0, 1, 1)  # return a single pixel on error ! TODO: make/return an error image
    ensure
      img.destroy!
    end
    cropped
  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image]
  def self.resized(params)
    c = cropped(params)
    resized = c.resize(params[:new_width].to_i, params[:new_height].to_i) #.sharpen(0x1)
    c.destroy!
    resized
  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image, nil]
  def self.scaled_to_box(params)
    return nil if params[:box_width].to_f == 0 || params[:box_height].to_f == 0
    begin
      c = cropped(params)
      ratio = c.columns.to_f / c.rows.to_f
      box_ratio = params[:box_width].to_f / params[:box_height].to_f
      # TODO: special considerations for 1:1?

      if box_ratio > 1
        if ratio > 1 # wide into wide
          scaled = c.resize(
            params[:box_width].to_i,
            (params[:box_height].to_f / ratio * box_ratio).to_i
          ) #.sharpen(0x1)
        else # tall into wide
          scaled = c.resize(
            (params[:box_width ].to_f * ratio / box_ratio).to_i,
            params[:box_height].to_i
          ) #.sharpen(0x1)
        end
      else # <
        if ratio > 1 # wide into tall
          scaled = c.resize(
            params[:box_width].to_i,
            (params[:box_height].to_f / ratio * box_ratio).to_i
          ) #.sharpen(0x1)
        else # tall into tall # TODO: or 1:1?!
          scaled = c.resize(
            (params[:box_width].to_f * ratio / box_ratio ).to_i,
            (params[:box_height].to_f ).to_i
          ) #.sharpen(0x1)
        end
      end
      c.destroy!
    rescue Magick::ImageMagickError
      nil
    end
    scaled
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.scaled_to_box_blob(params)
    self.to_blob!(scaled_to_box(params))
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.resized_blob(params)
    self.to_blob!(resized(params))
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.cropped_blob(params)
    self.to_blob!(cropped(params))
  end

  # @param used_on [String] required, a depictable base class name like  `Otu`, `Content`, or `CollectionObject`
  # @return [Scope]
  #   the max 10 most recently used images, as `used_on`
  def self.used_recently(user_id, project_id, used_on = '')
    i = arel_table
    d = Depiction.arel_table

    # i is a select manager
    j = d.project(d['image_id'], d['updated_at'], d['depiction_object_type']).from(d)
      .where(d['updated_at'].gt( 1.week.ago ))
      .where(d['updated_by_id'].eq(user_id))
      .where(d['project_id'].eq(project_id))
      .order(d['updated_at'].desc)

    z = j.as('recent_i')

    k = Arel::Nodes::InnerJoin.new(z, Arel::Nodes::On.new(
      z['image_id'].eq(i['id']).and(z['depiction_object_type'].eq(used_on))
    ))

    joins(k).distinct.pluck(:id)
  end

  # @params target [String] required, one of nil, `AssertedDistribution`, `Content`, `BiologicalAssociation`, 'TaxonDetermination'
  # @return [Hash] images optimized for user selection
  def self.select_optimized(user_id, project_id, target = nil)
    r = used_recently(user_id, project_id, target)
    h = {
      quick: [],
      pinboard: Image.pinned_by(user_id).where(project_id:).to_a,
      recent: []
    }

    if target && !r.empty?
      h[:recent] = (
        Image.where('"images"."id" IN (?)', r.first(5) ).to_a +
        Image.where(project_id:, created_by_id: user_id, created_at: 3.hours.ago..Time.now)
        .order('updated_at DESC')
        .limit(3).to_a
      ).uniq.sort{|a,b| a.updated_at <=> b.updated_at}

      h[:quick] = (
        Image.pinned_by(user_id).pinboard_inserted.where(project_id:).to_a +
        Image.where('"images"."id" IN (?)', r.first(4) ).to_a)
        .uniq.sort{|a,b| a.updated_at <=> b.updated_at}
    else
      h[:recent] = Image.where(project_id:).order('updated_at DESC').limit(10).to_a
      h[:quick] = Image.pinned_by(user_id).pinboard_inserted.where(pinboard_items: {project_id:}).order('updated_at DESC')
    end

    h
  end

  protected

  # @return [Integer, Nil]
  def extract_tw_attributes
    # NOTE: assumes content type is an image.
    tempfile = image_file.queued_for_write[:original]
    if tempfile
      self.user_file_name = tempfile.original_filename
      geometry = Paperclip::Geometry.from_file(tempfile)
      self.width = geometry.width.to_i
      self.height = geometry.height.to_i
    end
  end

  private

  # Converts image to blob and releases memory of img (image cannot be used afterwards)
  # @param [Magick::Image] img
  # @return [String] a JPG representation of the image
  #   !! Always converts to .jpg, this may need abstraction later
  #   Returns an empty string if no image
  def self.to_blob!(img)
    return '' if img.nil?
    img.format = 'jpg'
    blob = img.to_blob
    img.destroy!
    blob
  end

  def image_dimensions_too_short
    return unless original = image_file.queued_for_write[:original]

    dimensions = Paperclip::Geometry.from_file(original)

    errors.add(:image_file, 'width must be at least 16 pixels') if dimensions.width < 16
    errors.add(:image_file, 'height must be at least 16 pixels') if dimensions.height < 16
  rescue
    errors.add(:image_file, 'unable to extract image dimensions')
  end

end

#image_file_metaString

Added by paperclip_meta gem, stores the sizes of derived images

Returns:

  • (String)


49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
# File 'app/models/image.rb', line 49

class Image < ApplicationRecord
  include Housekeeping
  include Shared::Identifiers
  include Shared::Notes
  include Shared::Tags
  include Shared::ProtocolRelationships
  include Shared::Citations
  include Shared::Attributions
  include Shared::IsData
  include SoftValidation

  include Image::Sled

  attr_accessor :rotate

  # ANY non-blank? value here will attempt to also create a depiction 
  # for the Image, linking it to a CollectionObject
  attr_accessor :filename_depicts_object

  MISSING_IMAGE_PATH = '/public/images/missing.jpg'.freeze

  GRAPH_ENTRY_POINTS = [:depictions]

  DEFAULT_SIZES = {
    thumb: { width: 100, height: 100 },
    medium: { width: 300, height: 300 }
  }.freeze

  has_one :sled_image, dependent: :destroy, inverse_of: :image

  has_many :depictions, inverse_of: :image, dependent: :restrict_with_error

  has_many :collection_objects, through: :depictions, source: :depiction_object, source_type: 'CollectionObject'
  has_many :otus, through: :depictions, source: :depiction_object, source_type: 'Otu'
  has_many :taxon_names, through: :otus

  after_validation :stub_depiction, if: Proc.new {|n| !n.filename_depicts_object.blank?}
  before_save :extract_tw_attributes

  # also using https://github.com/teeparham/paperclip-meta
  has_attached_file :image_file,
    styles: {
      thumb: [ "#{DEFAULT_SIZES[:thumb][:width]}x#{DEFAULT_SIZES[:thumb][:height]}>", :png ] ,
      medium: [ "#{DEFAULT_SIZES[:medium][:width]}x#{DEFAULT_SIZES[:medium][:height]}>", :jpg ] },
  default_url: MISSING_IMAGE_PATH,
  filename_cleaner: Utilities::CleanseFilename,
  processors: [:rotator]

  #:restricted_characters => /[^A-Za-z0-9\.]/,
  validates_attachment_content_type :image_file, content_type: /\Aimage\/.*\Z/
  validates_attachment_presence :image_file
  validate :image_dimensions_too_short

  validates_uniqueness_of :image_file_fingerprint, scope: :project_id

  accepts_nested_attributes_for :sled_image, allow_destroy: true

  accepts_nested_attributes_for :depictions, allow_destroy: false

  # This is bad and you should feel bad if your digitization workflow uses it.
  def stub_depiction
    identifier = File.basename(
      image_file.queued_for_write[:original].original_filename,
      '.*'
    )

    co = CollectionObject.joins(:identifiers).where(
      identifiers: {cached: identifier},
      project_id: Current.project_id,
    ).first

    if co.nil?
      errors.add(:base, 'filename does not match any known CollectionObject identifier')
      return
    end

    depictions.build(
      depiction_object_id: co.id,
      depiction_object_type: 'CollectionObject',
      by: Current.user_id,
      project_id: Current.project_id
    )
  end

  # Replaces Image.create!
  def self.deduplicate_create(image_params)
    image = Image.new(image_params)

    if i = Image.where(project_id: Current.project_id, image_file_fingerprint: image.image_file_fingerprint).first
      return i
    else
      return image
    end
  end

  def sqed_depiction
    depictions.joins(:sqed_depiction).first&.sqed_depiction
  end

  # TODO:
  # Deprecated, once images are de-duplicated
  #   this will be removed
  # @return [Boolean]
  def has_duplicate?
    Image.where(image_file_fingerprint: self.image_file_fingerprint).count > 1
  end

  # TODO:
  # Deprecated, once images are de-duplicated
  #   this will be removed. No duplicate images can now be created
  # @return [Array]
  def duplicate_images
    Image.where(image_file_fingerprint: self.image_file_fingerprint).not_self(self).to_a
  end

  # @return [Hash]
  # returns a hash of EXIF data if present, empty hash if not.do
  # EXIF data tags/specifications -  http://web.archive.org/web/20131018091152/http://exif.org/Exif2-2.PDF
  def exif
    ret_val = {} # return value

    unless self.new_record? # only process if record exists
      tmp     = `identify -format "%[EXIF:*]" #{self.image_file.url}` # returns a string (exif:tag=value\n)
      # following removes the exif, spits and recombines string as a hash
      ret_val = tmp.split("\n").collect { |b| b.gsub('exif:', '').split('=') }
        .inject({}) { |hsh, c| hsh.merge(c[0] => c[1]) }
      # might be able to tmp.split("\n").collect { |b|
      # b.gsub("exif:", "").split("=")
      # }.inject(ret_val) { |hsh, c|
      #   hsh.merge(c[0] => c[1])
      # }
    end

    ret_val # return
  end

  # TODO: move to /lib
  # @return [Nil]
  #  currently handling this client side
  def gps_data
    # if there is EXIF data, pulls out geographic coordinates & returns hash of lat/long in decimal degrees
    # (5 digits after decimal point if available)
    # EXIF gps information is in http://web.archive.org/web/20131018091152/http://exif.org/Exif2-2.PDF section 4.6.6
    # note that cameras follow specifications, but EXIF data can be edited manually and may not follow specifications.

    # check if gps data is in d m s (could be edited manually)
    #   => format dd/1,mm/1,ss/1 or dd/1,mmmm/100,0/1 or 40/1, 5/1, 314437/10000
    # N = +
    # S = -
    # E = +
    # W = -
    # Altitude should be based on reference of sea level
    # GPSAltitudeRef is 0 for above sea level, and 1 for below sea level

    # From discussion with Jim -
    # create a utility library called "GeoConvert" and define single method
    # that will convert from degrees min sec to decimal degree
    # - maybe 2 versions? - one returns string, other decimal?
  end

  # Returns the true, unscaled height/width ratio
  # @return [Float]
  def hw_ratio
    raise if height.nil? || width.nil? # if they are something has gone badly wrong
    return (height.to_f / width.to_f)
  end

  # rubocop:disable Style/StringHashKeys
  # used in ImageHelper#image_thumb_tag
  # asthetic scaling of very narrow images in thumbnails
  # @return [Hash]
  def thumb_scaler
    a = self.hw_ratio
    if a < 0.6
      { 'width' => 200, 'height' => 200 * a}
    else
      {}
    end
  end
  # rubocop:enable Style/StringHashKeys

  # the scale factor is typically the same except in a few cases where we skew small thumbs
  # @param [Symbol] size
  # @return [Float]
  def width_scale_for_size(size = :medium)
    (width_for_size(size).to_f / width.to_f)
  end

  # @param [Symbol] size
  # @return [Float]
  def height_scale_for_size(size = :medium)
    height_for_size(size).to_f / height.to_f
  end

  # @param [Symbol] size
  # @return [Float]
  def width_for_size(size = :medium)
    a = self.hw_ratio
    case size
    when :thumb
      a < 0.6 ? 200.0 : ((width.to_f / height.to_f ) * 160.0)
    when :medium
      a < 1 ? 640.0 : 640.0 / a
    when :big
      a < 1 ? 1600.0 : 1600.0 / a
    when :original
      width
    else
      nil
    end
  end

  # @param [Symbol] size
  # @return [Float]
  def height_for_size(size = :medium)
    a = self.hw_ratio
    case size
    when :thumb
      a < 0.6 ? 213.0 * height.to_f / width.to_f : 160
    when :medium
      a < 1 ? a * 640 : 640
    when :big
      a < 1 ? a * 1600 : 1600
    when :original
      height
    else
      nil
    end
  end

  #  def filename(layout_section_type)
  #    'tmp/' + tempfile(layout_section_type).path.split('/').last
  #  end

  #  def tempfile(layout_section_type)
  #    tempfile = Tempfile.new([layout_section_type.to_s, '.jpg'], "#{Rails.root.to_s}/public/images/tmp", encoding: 'ASCII-8BIT' )
  #    tempfile.write(zoomed_image(layout_section_type).to_blob)
  #    tempfile
  #  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image]
  def self.cropped(params)
    image = Image.find(params[:id])
    img = Magick::Image.read(image.image_file.path(:original)).first
    begin
      # img.crop(x, y, width, height, true)
      cropped = img.crop( params[:x].to_i, params[:y].to_i, params[:width].to_i, params[:height].to_i, true)
    rescue RuntimeError
      cropped = img.crop(0,0, 1, 1)  # return a single pixel on error ! TODO: make/return an error image
    ensure
      img.destroy!
    end
    cropped
  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image]
  def self.resized(params)
    c = cropped(params)
    resized = c.resize(params[:new_width].to_i, params[:new_height].to_i) #.sharpen(0x1)
    c.destroy!
    resized
  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image, nil]
  def self.scaled_to_box(params)
    return nil if params[:box_width].to_f == 0 || params[:box_height].to_f == 0
    begin
      c = cropped(params)
      ratio = c.columns.to_f / c.rows.to_f
      box_ratio = params[:box_width].to_f / params[:box_height].to_f
      # TODO: special considerations for 1:1?

      if box_ratio > 1
        if ratio > 1 # wide into wide
          scaled = c.resize(
            params[:box_width].to_i,
            (params[:box_height].to_f / ratio * box_ratio).to_i
          ) #.sharpen(0x1)
        else # tall into wide
          scaled = c.resize(
            (params[:box_width ].to_f * ratio / box_ratio).to_i,
            params[:box_height].to_i
          ) #.sharpen(0x1)
        end
      else # <
        if ratio > 1 # wide into tall
          scaled = c.resize(
            params[:box_width].to_i,
            (params[:box_height].to_f / ratio * box_ratio).to_i
          ) #.sharpen(0x1)
        else # tall into tall # TODO: or 1:1?!
          scaled = c.resize(
            (params[:box_width].to_f * ratio / box_ratio ).to_i,
            (params[:box_height].to_f ).to_i
          ) #.sharpen(0x1)
        end
      end
      c.destroy!
    rescue Magick::ImageMagickError
      nil
    end
    scaled
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.scaled_to_box_blob(params)
    self.to_blob!(scaled_to_box(params))
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.resized_blob(params)
    self.to_blob!(resized(params))
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.cropped_blob(params)
    self.to_blob!(cropped(params))
  end

  # @param used_on [String] required, a depictable base class name like  `Otu`, `Content`, or `CollectionObject`
  # @return [Scope]
  #   the max 10 most recently used images, as `used_on`
  def self.used_recently(user_id, project_id, used_on = '')
    i = arel_table
    d = Depiction.arel_table

    # i is a select manager
    j = d.project(d['image_id'], d['updated_at'], d['depiction_object_type']).from(d)
      .where(d['updated_at'].gt( 1.week.ago ))
      .where(d['updated_by_id'].eq(user_id))
      .where(d['project_id'].eq(project_id))
      .order(d['updated_at'].desc)

    z = j.as('recent_i')

    k = Arel::Nodes::InnerJoin.new(z, Arel::Nodes::On.new(
      z['image_id'].eq(i['id']).and(z['depiction_object_type'].eq(used_on))
    ))

    joins(k).distinct.pluck(:id)
  end

  # @params target [String] required, one of nil, `AssertedDistribution`, `Content`, `BiologicalAssociation`, 'TaxonDetermination'
  # @return [Hash] images optimized for user selection
  def self.select_optimized(user_id, project_id, target = nil)
    r = used_recently(user_id, project_id, target)
    h = {
      quick: [],
      pinboard: Image.pinned_by(user_id).where(project_id:).to_a,
      recent: []
    }

    if target && !r.empty?
      h[:recent] = (
        Image.where('"images"."id" IN (?)', r.first(5) ).to_a +
        Image.where(project_id:, created_by_id: user_id, created_at: 3.hours.ago..Time.now)
        .order('updated_at DESC')
        .limit(3).to_a
      ).uniq.sort{|a,b| a.updated_at <=> b.updated_at}

      h[:quick] = (
        Image.pinned_by(user_id).pinboard_inserted.where(project_id:).to_a +
        Image.where('"images"."id" IN (?)', r.first(4) ).to_a)
        .uniq.sort{|a,b| a.updated_at <=> b.updated_at}
    else
      h[:recent] = Image.where(project_id:).order('updated_at DESC').limit(10).to_a
      h[:quick] = Image.pinned_by(user_id).pinboard_inserted.where(pinboard_items: {project_id:}).order('updated_at DESC')
    end

    h
  end

  protected

  # @return [Integer, Nil]
  def extract_tw_attributes
    # NOTE: assumes content type is an image.
    tempfile = image_file.queued_for_write[:original]
    if tempfile
      self.user_file_name = tempfile.original_filename
      geometry = Paperclip::Geometry.from_file(tempfile)
      self.width = geometry.width.to_i
      self.height = geometry.height.to_i
    end
  end

  private

  # Converts image to blob and releases memory of img (image cannot be used afterwards)
  # @param [Magick::Image] img
  # @return [String] a JPG representation of the image
  #   !! Always converts to .jpg, this may need abstraction later
  #   Returns an empty string if no image
  def self.to_blob!(img)
    return '' if img.nil?
    img.format = 'jpg'
    blob = img.to_blob
    img.destroy!
    blob
  end

  def image_dimensions_too_short
    return unless original = image_file.queued_for_write[:original]

    dimensions = Paperclip::Geometry.from_file(original)

    errors.add(:image_file, 'width must be at least 16 pixels') if dimensions.width < 16
    errors.add(:image_file, 'height must be at least 16 pixels') if dimensions.height < 16
  rescue
    errors.add(:image_file, 'unable to extract image dimensions')
  end

end

#image_file_updated_atInteger

Added by paperclip

Returns:

  • (Integer)


49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
# File 'app/models/image.rb', line 49

class Image < ApplicationRecord
  include Housekeeping
  include Shared::Identifiers
  include Shared::Notes
  include Shared::Tags
  include Shared::ProtocolRelationships
  include Shared::Citations
  include Shared::Attributions
  include Shared::IsData
  include SoftValidation

  include Image::Sled

  attr_accessor :rotate

  # ANY non-blank? value here will attempt to also create a depiction 
  # for the Image, linking it to a CollectionObject
  attr_accessor :filename_depicts_object

  MISSING_IMAGE_PATH = '/public/images/missing.jpg'.freeze

  GRAPH_ENTRY_POINTS = [:depictions]

  DEFAULT_SIZES = {
    thumb: { width: 100, height: 100 },
    medium: { width: 300, height: 300 }
  }.freeze

  has_one :sled_image, dependent: :destroy, inverse_of: :image

  has_many :depictions, inverse_of: :image, dependent: :restrict_with_error

  has_many :collection_objects, through: :depictions, source: :depiction_object, source_type: 'CollectionObject'
  has_many :otus, through: :depictions, source: :depiction_object, source_type: 'Otu'
  has_many :taxon_names, through: :otus

  after_validation :stub_depiction, if: Proc.new {|n| !n.filename_depicts_object.blank?}
  before_save :extract_tw_attributes

  # also using https://github.com/teeparham/paperclip-meta
  has_attached_file :image_file,
    styles: {
      thumb: [ "#{DEFAULT_SIZES[:thumb][:width]}x#{DEFAULT_SIZES[:thumb][:height]}>", :png ] ,
      medium: [ "#{DEFAULT_SIZES[:medium][:width]}x#{DEFAULT_SIZES[:medium][:height]}>", :jpg ] },
  default_url: MISSING_IMAGE_PATH,
  filename_cleaner: Utilities::CleanseFilename,
  processors: [:rotator]

  #:restricted_characters => /[^A-Za-z0-9\.]/,
  validates_attachment_content_type :image_file, content_type: /\Aimage\/.*\Z/
  validates_attachment_presence :image_file
  validate :image_dimensions_too_short

  validates_uniqueness_of :image_file_fingerprint, scope: :project_id

  accepts_nested_attributes_for :sled_image, allow_destroy: true

  accepts_nested_attributes_for :depictions, allow_destroy: false

  # This is bad and you should feel bad if your digitization workflow uses it.
  def stub_depiction
    identifier = File.basename(
      image_file.queued_for_write[:original].original_filename,
      '.*'
    )

    co = CollectionObject.joins(:identifiers).where(
      identifiers: {cached: identifier},
      project_id: Current.project_id,
    ).first

    if co.nil?
      errors.add(:base, 'filename does not match any known CollectionObject identifier')
      return
    end

    depictions.build(
      depiction_object_id: co.id,
      depiction_object_type: 'CollectionObject',
      by: Current.user_id,
      project_id: Current.project_id
    )
  end

  # Replaces Image.create!
  def self.deduplicate_create(image_params)
    image = Image.new(image_params)

    if i = Image.where(project_id: Current.project_id, image_file_fingerprint: image.image_file_fingerprint).first
      return i
    else
      return image
    end
  end

  def sqed_depiction
    depictions.joins(:sqed_depiction).first&.sqed_depiction
  end

  # TODO:
  # Deprecated, once images are de-duplicated
  #   this will be removed
  # @return [Boolean]
  def has_duplicate?
    Image.where(image_file_fingerprint: self.image_file_fingerprint).count > 1
  end

  # TODO:
  # Deprecated, once images are de-duplicated
  #   this will be removed. No duplicate images can now be created
  # @return [Array]
  def duplicate_images
    Image.where(image_file_fingerprint: self.image_file_fingerprint).not_self(self).to_a
  end

  # @return [Hash]
  # returns a hash of EXIF data if present, empty hash if not.do
  # EXIF data tags/specifications -  http://web.archive.org/web/20131018091152/http://exif.org/Exif2-2.PDF
  def exif
    ret_val = {} # return value

    unless self.new_record? # only process if record exists
      tmp     = `identify -format "%[EXIF:*]" #{self.image_file.url}` # returns a string (exif:tag=value\n)
      # following removes the exif, spits and recombines string as a hash
      ret_val = tmp.split("\n").collect { |b| b.gsub('exif:', '').split('=') }
        .inject({}) { |hsh, c| hsh.merge(c[0] => c[1]) }
      # might be able to tmp.split("\n").collect { |b|
      # b.gsub("exif:", "").split("=")
      # }.inject(ret_val) { |hsh, c|
      #   hsh.merge(c[0] => c[1])
      # }
    end

    ret_val # return
  end

  # TODO: move to /lib
  # @return [Nil]
  #  currently handling this client side
  def gps_data
    # if there is EXIF data, pulls out geographic coordinates & returns hash of lat/long in decimal degrees
    # (5 digits after decimal point if available)
    # EXIF gps information is in http://web.archive.org/web/20131018091152/http://exif.org/Exif2-2.PDF section 4.6.6
    # note that cameras follow specifications, but EXIF data can be edited manually and may not follow specifications.

    # check if gps data is in d m s (could be edited manually)
    #   => format dd/1,mm/1,ss/1 or dd/1,mmmm/100,0/1 or 40/1, 5/1, 314437/10000
    # N = +
    # S = -
    # E = +
    # W = -
    # Altitude should be based on reference of sea level
    # GPSAltitudeRef is 0 for above sea level, and 1 for below sea level

    # From discussion with Jim -
    # create a utility library called "GeoConvert" and define single method
    # that will convert from degrees min sec to decimal degree
    # - maybe 2 versions? - one returns string, other decimal?
  end

  # Returns the true, unscaled height/width ratio
  # @return [Float]
  def hw_ratio
    raise if height.nil? || width.nil? # if they are something has gone badly wrong
    return (height.to_f / width.to_f)
  end

  # rubocop:disable Style/StringHashKeys
  # used in ImageHelper#image_thumb_tag
  # asthetic scaling of very narrow images in thumbnails
  # @return [Hash]
  def thumb_scaler
    a = self.hw_ratio
    if a < 0.6
      { 'width' => 200, 'height' => 200 * a}
    else
      {}
    end
  end
  # rubocop:enable Style/StringHashKeys

  # the scale factor is typically the same except in a few cases where we skew small thumbs
  # @param [Symbol] size
  # @return [Float]
  def width_scale_for_size(size = :medium)
    (width_for_size(size).to_f / width.to_f)
  end

  # @param [Symbol] size
  # @return [Float]
  def height_scale_for_size(size = :medium)
    height_for_size(size).to_f / height.to_f
  end

  # @param [Symbol] size
  # @return [Float]
  def width_for_size(size = :medium)
    a = self.hw_ratio
    case size
    when :thumb
      a < 0.6 ? 200.0 : ((width.to_f / height.to_f ) * 160.0)
    when :medium
      a < 1 ? 640.0 : 640.0 / a
    when :big
      a < 1 ? 1600.0 : 1600.0 / a
    when :original
      width
    else
      nil
    end
  end

  # @param [Symbol] size
  # @return [Float]
  def height_for_size(size = :medium)
    a = self.hw_ratio
    case size
    when :thumb
      a < 0.6 ? 213.0 * height.to_f / width.to_f : 160
    when :medium
      a < 1 ? a * 640 : 640
    when :big
      a < 1 ? a * 1600 : 1600
    when :original
      height
    else
      nil
    end
  end

  #  def filename(layout_section_type)
  #    'tmp/' + tempfile(layout_section_type).path.split('/').last
  #  end

  #  def tempfile(layout_section_type)
  #    tempfile = Tempfile.new([layout_section_type.to_s, '.jpg'], "#{Rails.root.to_s}/public/images/tmp", encoding: 'ASCII-8BIT' )
  #    tempfile.write(zoomed_image(layout_section_type).to_blob)
  #    tempfile
  #  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image]
  def self.cropped(params)
    image = Image.find(params[:id])
    img = Magick::Image.read(image.image_file.path(:original)).first
    begin
      # img.crop(x, y, width, height, true)
      cropped = img.crop( params[:x].to_i, params[:y].to_i, params[:width].to_i, params[:height].to_i, true)
    rescue RuntimeError
      cropped = img.crop(0,0, 1, 1)  # return a single pixel on error ! TODO: make/return an error image
    ensure
      img.destroy!
    end
    cropped
  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image]
  def self.resized(params)
    c = cropped(params)
    resized = c.resize(params[:new_width].to_i, params[:new_height].to_i) #.sharpen(0x1)
    c.destroy!
    resized
  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image, nil]
  def self.scaled_to_box(params)
    return nil if params[:box_width].to_f == 0 || params[:box_height].to_f == 0
    begin
      c = cropped(params)
      ratio = c.columns.to_f / c.rows.to_f
      box_ratio = params[:box_width].to_f / params[:box_height].to_f
      # TODO: special considerations for 1:1?

      if box_ratio > 1
        if ratio > 1 # wide into wide
          scaled = c.resize(
            params[:box_width].to_i,
            (params[:box_height].to_f / ratio * box_ratio).to_i
          ) #.sharpen(0x1)
        else # tall into wide
          scaled = c.resize(
            (params[:box_width ].to_f * ratio / box_ratio).to_i,
            params[:box_height].to_i
          ) #.sharpen(0x1)
        end
      else # <
        if ratio > 1 # wide into tall
          scaled = c.resize(
            params[:box_width].to_i,
            (params[:box_height].to_f / ratio * box_ratio).to_i
          ) #.sharpen(0x1)
        else # tall into tall # TODO: or 1:1?!
          scaled = c.resize(
            (params[:box_width].to_f * ratio / box_ratio ).to_i,
            (params[:box_height].to_f ).to_i
          ) #.sharpen(0x1)
        end
      end
      c.destroy!
    rescue Magick::ImageMagickError
      nil
    end
    scaled
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.scaled_to_box_blob(params)
    self.to_blob!(scaled_to_box(params))
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.resized_blob(params)
    self.to_blob!(resized(params))
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.cropped_blob(params)
    self.to_blob!(cropped(params))
  end

  # @param used_on [String] required, a depictable base class name like  `Otu`, `Content`, or `CollectionObject`
  # @return [Scope]
  #   the max 10 most recently used images, as `used_on`
  def self.used_recently(user_id, project_id, used_on = '')
    i = arel_table
    d = Depiction.arel_table

    # i is a select manager
    j = d.project(d['image_id'], d['updated_at'], d['depiction_object_type']).from(d)
      .where(d['updated_at'].gt( 1.week.ago ))
      .where(d['updated_by_id'].eq(user_id))
      .where(d['project_id'].eq(project_id))
      .order(d['updated_at'].desc)

    z = j.as('recent_i')

    k = Arel::Nodes::InnerJoin.new(z, Arel::Nodes::On.new(
      z['image_id'].eq(i['id']).and(z['depiction_object_type'].eq(used_on))
    ))

    joins(k).distinct.pluck(:id)
  end

  # @params target [String] required, one of nil, `AssertedDistribution`, `Content`, `BiologicalAssociation`, 'TaxonDetermination'
  # @return [Hash] images optimized for user selection
  def self.select_optimized(user_id, project_id, target = nil)
    r = used_recently(user_id, project_id, target)
    h = {
      quick: [],
      pinboard: Image.pinned_by(user_id).where(project_id:).to_a,
      recent: []
    }

    if target && !r.empty?
      h[:recent] = (
        Image.where('"images"."id" IN (?)', r.first(5) ).to_a +
        Image.where(project_id:, created_by_id: user_id, created_at: 3.hours.ago..Time.now)
        .order('updated_at DESC')
        .limit(3).to_a
      ).uniq.sort{|a,b| a.updated_at <=> b.updated_at}

      h[:quick] = (
        Image.pinned_by(user_id).pinboard_inserted.where(project_id:).to_a +
        Image.where('"images"."id" IN (?)', r.first(4) ).to_a)
        .uniq.sort{|a,b| a.updated_at <=> b.updated_at}
    else
      h[:recent] = Image.where(project_id:).order('updated_at DESC').limit(10).to_a
      h[:quick] = Image.pinned_by(user_id).pinboard_inserted.where(pinboard_items: {project_id:}).order('updated_at DESC')
    end

    h
  end

  protected

  # @return [Integer, Nil]
  def extract_tw_attributes
    # NOTE: assumes content type is an image.
    tempfile = image_file.queued_for_write[:original]
    if tempfile
      self.user_file_name = tempfile.original_filename
      geometry = Paperclip::Geometry.from_file(tempfile)
      self.width = geometry.width.to_i
      self.height = geometry.height.to_i
    end
  end

  private

  # Converts image to blob and releases memory of img (image cannot be used afterwards)
  # @param [Magick::Image] img
  # @return [String] a JPG representation of the image
  #   !! Always converts to .jpg, this may need abstraction later
  #   Returns an empty string if no image
  def self.to_blob!(img)
    return '' if img.nil?
    img.format = 'jpg'
    blob = img.to_blob
    img.destroy!
    blob
  end

  def image_dimensions_too_short
    return unless original = image_file.queued_for_write[:original]

    dimensions = Paperclip::Geometry.from_file(original)

    errors.add(:image_file, 'width must be at least 16 pixels') if dimensions.width < 16
    errors.add(:image_file, 'height must be at least 16 pixels') if dimensions.height < 16
  rescue
    errors.add(:image_file, 'unable to extract image dimensions')
  end

end

#pixels_to_centimeterFloat?

Returns used to generate scale bars on the fly.

Returns:

  • (Float, nil)

    used to generate scale bars on the fly



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
# File 'app/models/image.rb', line 49

class Image < ApplicationRecord
  include Housekeeping
  include Shared::Identifiers
  include Shared::Notes
  include Shared::Tags
  include Shared::ProtocolRelationships
  include Shared::Citations
  include Shared::Attributions
  include Shared::IsData
  include SoftValidation

  include Image::Sled

  attr_accessor :rotate

  # ANY non-blank? value here will attempt to also create a depiction 
  # for the Image, linking it to a CollectionObject
  attr_accessor :filename_depicts_object

  MISSING_IMAGE_PATH = '/public/images/missing.jpg'.freeze

  GRAPH_ENTRY_POINTS = [:depictions]

  DEFAULT_SIZES = {
    thumb: { width: 100, height: 100 },
    medium: { width: 300, height: 300 }
  }.freeze

  has_one :sled_image, dependent: :destroy, inverse_of: :image

  has_many :depictions, inverse_of: :image, dependent: :restrict_with_error

  has_many :collection_objects, through: :depictions, source: :depiction_object, source_type: 'CollectionObject'
  has_many :otus, through: :depictions, source: :depiction_object, source_type: 'Otu'
  has_many :taxon_names, through: :otus

  after_validation :stub_depiction, if: Proc.new {|n| !n.filename_depicts_object.blank?}
  before_save :extract_tw_attributes

  # also using https://github.com/teeparham/paperclip-meta
  has_attached_file :image_file,
    styles: {
      thumb: [ "#{DEFAULT_SIZES[:thumb][:width]}x#{DEFAULT_SIZES[:thumb][:height]}>", :png ] ,
      medium: [ "#{DEFAULT_SIZES[:medium][:width]}x#{DEFAULT_SIZES[:medium][:height]}>", :jpg ] },
  default_url: MISSING_IMAGE_PATH,
  filename_cleaner: Utilities::CleanseFilename,
  processors: [:rotator]

  #:restricted_characters => /[^A-Za-z0-9\.]/,
  validates_attachment_content_type :image_file, content_type: /\Aimage\/.*\Z/
  validates_attachment_presence :image_file
  validate :image_dimensions_too_short

  validates_uniqueness_of :image_file_fingerprint, scope: :project_id

  accepts_nested_attributes_for :sled_image, allow_destroy: true

  accepts_nested_attributes_for :depictions, allow_destroy: false

  # This is bad and you should feel bad if your digitization workflow uses it.
  def stub_depiction
    identifier = File.basename(
      image_file.queued_for_write[:original].original_filename,
      '.*'
    )

    co = CollectionObject.joins(:identifiers).where(
      identifiers: {cached: identifier},
      project_id: Current.project_id,
    ).first

    if co.nil?
      errors.add(:base, 'filename does not match any known CollectionObject identifier')
      return
    end

    depictions.build(
      depiction_object_id: co.id,
      depiction_object_type: 'CollectionObject',
      by: Current.user_id,
      project_id: Current.project_id
    )
  end

  # Replaces Image.create!
  def self.deduplicate_create(image_params)
    image = Image.new(image_params)

    if i = Image.where(project_id: Current.project_id, image_file_fingerprint: image.image_file_fingerprint).first
      return i
    else
      return image
    end
  end

  def sqed_depiction
    depictions.joins(:sqed_depiction).first&.sqed_depiction
  end

  # TODO:
  # Deprecated, once images are de-duplicated
  #   this will be removed
  # @return [Boolean]
  def has_duplicate?
    Image.where(image_file_fingerprint: self.image_file_fingerprint).count > 1
  end

  # TODO:
  # Deprecated, once images are de-duplicated
  #   this will be removed. No duplicate images can now be created
  # @return [Array]
  def duplicate_images
    Image.where(image_file_fingerprint: self.image_file_fingerprint).not_self(self).to_a
  end

  # @return [Hash]
  # returns a hash of EXIF data if present, empty hash if not.do
  # EXIF data tags/specifications -  http://web.archive.org/web/20131018091152/http://exif.org/Exif2-2.PDF
  def exif
    ret_val = {} # return value

    unless self.new_record? # only process if record exists
      tmp     = `identify -format "%[EXIF:*]" #{self.image_file.url}` # returns a string (exif:tag=value\n)
      # following removes the exif, spits and recombines string as a hash
      ret_val = tmp.split("\n").collect { |b| b.gsub('exif:', '').split('=') }
        .inject({}) { |hsh, c| hsh.merge(c[0] => c[1]) }
      # might be able to tmp.split("\n").collect { |b|
      # b.gsub("exif:", "").split("=")
      # }.inject(ret_val) { |hsh, c|
      #   hsh.merge(c[0] => c[1])
      # }
    end

    ret_val # return
  end

  # TODO: move to /lib
  # @return [Nil]
  #  currently handling this client side
  def gps_data
    # if there is EXIF data, pulls out geographic coordinates & returns hash of lat/long in decimal degrees
    # (5 digits after decimal point if available)
    # EXIF gps information is in http://web.archive.org/web/20131018091152/http://exif.org/Exif2-2.PDF section 4.6.6
    # note that cameras follow specifications, but EXIF data can be edited manually and may not follow specifications.

    # check if gps data is in d m s (could be edited manually)
    #   => format dd/1,mm/1,ss/1 or dd/1,mmmm/100,0/1 or 40/1, 5/1, 314437/10000
    # N = +
    # S = -
    # E = +
    # W = -
    # Altitude should be based on reference of sea level
    # GPSAltitudeRef is 0 for above sea level, and 1 for below sea level

    # From discussion with Jim -
    # create a utility library called "GeoConvert" and define single method
    # that will convert from degrees min sec to decimal degree
    # - maybe 2 versions? - one returns string, other decimal?
  end

  # Returns the true, unscaled height/width ratio
  # @return [Float]
  def hw_ratio
    raise if height.nil? || width.nil? # if they are something has gone badly wrong
    return (height.to_f / width.to_f)
  end

  # rubocop:disable Style/StringHashKeys
  # used in ImageHelper#image_thumb_tag
  # asthetic scaling of very narrow images in thumbnails
  # @return [Hash]
  def thumb_scaler
    a = self.hw_ratio
    if a < 0.6
      { 'width' => 200, 'height' => 200 * a}
    else
      {}
    end
  end
  # rubocop:enable Style/StringHashKeys

  # the scale factor is typically the same except in a few cases where we skew small thumbs
  # @param [Symbol] size
  # @return [Float]
  def width_scale_for_size(size = :medium)
    (width_for_size(size).to_f / width.to_f)
  end

  # @param [Symbol] size
  # @return [Float]
  def height_scale_for_size(size = :medium)
    height_for_size(size).to_f / height.to_f
  end

  # @param [Symbol] size
  # @return [Float]
  def width_for_size(size = :medium)
    a = self.hw_ratio
    case size
    when :thumb
      a < 0.6 ? 200.0 : ((width.to_f / height.to_f ) * 160.0)
    when :medium
      a < 1 ? 640.0 : 640.0 / a
    when :big
      a < 1 ? 1600.0 : 1600.0 / a
    when :original
      width
    else
      nil
    end
  end

  # @param [Symbol] size
  # @return [Float]
  def height_for_size(size = :medium)
    a = self.hw_ratio
    case size
    when :thumb
      a < 0.6 ? 213.0 * height.to_f / width.to_f : 160
    when :medium
      a < 1 ? a * 640 : 640
    when :big
      a < 1 ? a * 1600 : 1600
    when :original
      height
    else
      nil
    end
  end

  #  def filename(layout_section_type)
  #    'tmp/' + tempfile(layout_section_type).path.split('/').last
  #  end

  #  def tempfile(layout_section_type)
  #    tempfile = Tempfile.new([layout_section_type.to_s, '.jpg'], "#{Rails.root.to_s}/public/images/tmp", encoding: 'ASCII-8BIT' )
  #    tempfile.write(zoomed_image(layout_section_type).to_blob)
  #    tempfile
  #  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image]
  def self.cropped(params)
    image = Image.find(params[:id])
    img = Magick::Image.read(image.image_file.path(:original)).first
    begin
      # img.crop(x, y, width, height, true)
      cropped = img.crop( params[:x].to_i, params[:y].to_i, params[:width].to_i, params[:height].to_i, true)
    rescue RuntimeError
      cropped = img.crop(0,0, 1, 1)  # return a single pixel on error ! TODO: make/return an error image
    ensure
      img.destroy!
    end
    cropped
  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image]
  def self.resized(params)
    c = cropped(params)
    resized = c.resize(params[:new_width].to_i, params[:new_height].to_i) #.sharpen(0x1)
    c.destroy!
    resized
  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image, nil]
  def self.scaled_to_box(params)
    return nil if params[:box_width].to_f == 0 || params[:box_height].to_f == 0
    begin
      c = cropped(params)
      ratio = c.columns.to_f / c.rows.to_f
      box_ratio = params[:box_width].to_f / params[:box_height].to_f
      # TODO: special considerations for 1:1?

      if box_ratio > 1
        if ratio > 1 # wide into wide
          scaled = c.resize(
            params[:box_width].to_i,
            (params[:box_height].to_f / ratio * box_ratio).to_i
          ) #.sharpen(0x1)
        else # tall into wide
          scaled = c.resize(
            (params[:box_width ].to_f * ratio / box_ratio).to_i,
            params[:box_height].to_i
          ) #.sharpen(0x1)
        end
      else # <
        if ratio > 1 # wide into tall
          scaled = c.resize(
            params[:box_width].to_i,
            (params[:box_height].to_f / ratio * box_ratio).to_i
          ) #.sharpen(0x1)
        else # tall into tall # TODO: or 1:1?!
          scaled = c.resize(
            (params[:box_width].to_f * ratio / box_ratio ).to_i,
            (params[:box_height].to_f ).to_i
          ) #.sharpen(0x1)
        end
      end
      c.destroy!
    rescue Magick::ImageMagickError
      nil
    end
    scaled
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.scaled_to_box_blob(params)
    self.to_blob!(scaled_to_box(params))
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.resized_blob(params)
    self.to_blob!(resized(params))
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.cropped_blob(params)
    self.to_blob!(cropped(params))
  end

  # @param used_on [String] required, a depictable base class name like  `Otu`, `Content`, or `CollectionObject`
  # @return [Scope]
  #   the max 10 most recently used images, as `used_on`
  def self.used_recently(user_id, project_id, used_on = '')
    i = arel_table
    d = Depiction.arel_table

    # i is a select manager
    j = d.project(d['image_id'], d['updated_at'], d['depiction_object_type']).from(d)
      .where(d['updated_at'].gt( 1.week.ago ))
      .where(d['updated_by_id'].eq(user_id))
      .where(d['project_id'].eq(project_id))
      .order(d['updated_at'].desc)

    z = j.as('recent_i')

    k = Arel::Nodes::InnerJoin.new(z, Arel::Nodes::On.new(
      z['image_id'].eq(i['id']).and(z['depiction_object_type'].eq(used_on))
    ))

    joins(k).distinct.pluck(:id)
  end

  # @params target [String] required, one of nil, `AssertedDistribution`, `Content`, `BiologicalAssociation`, 'TaxonDetermination'
  # @return [Hash] images optimized for user selection
  def self.select_optimized(user_id, project_id, target = nil)
    r = used_recently(user_id, project_id, target)
    h = {
      quick: [],
      pinboard: Image.pinned_by(user_id).where(project_id:).to_a,
      recent: []
    }

    if target && !r.empty?
      h[:recent] = (
        Image.where('"images"."id" IN (?)', r.first(5) ).to_a +
        Image.where(project_id:, created_by_id: user_id, created_at: 3.hours.ago..Time.now)
        .order('updated_at DESC')
        .limit(3).to_a
      ).uniq.sort{|a,b| a.updated_at <=> b.updated_at}

      h[:quick] = (
        Image.pinned_by(user_id).pinboard_inserted.where(project_id:).to_a +
        Image.where('"images"."id" IN (?)', r.first(4) ).to_a)
        .uniq.sort{|a,b| a.updated_at <=> b.updated_at}
    else
      h[:recent] = Image.where(project_id:).order('updated_at DESC').limit(10).to_a
      h[:quick] = Image.pinned_by(user_id).pinboard_inserted.where(pinboard_items: {project_id:}).order('updated_at DESC')
    end

    h
  end

  protected

  # @return [Integer, Nil]
  def extract_tw_attributes
    # NOTE: assumes content type is an image.
    tempfile = image_file.queued_for_write[:original]
    if tempfile
      self.user_file_name = tempfile.original_filename
      geometry = Paperclip::Geometry.from_file(tempfile)
      self.width = geometry.width.to_i
      self.height = geometry.height.to_i
    end
  end

  private

  # Converts image to blob and releases memory of img (image cannot be used afterwards)
  # @param [Magick::Image] img
  # @return [String] a JPG representation of the image
  #   !! Always converts to .jpg, this may need abstraction later
  #   Returns an empty string if no image
  def self.to_blob!(img)
    return '' if img.nil?
    img.format = 'jpg'
    blob = img.to_blob
    img.destroy!
    blob
  end

  def image_dimensions_too_short
    return unless original = image_file.queued_for_write[:original]

    dimensions = Paperclip::Geometry.from_file(original)

    errors.add(:image_file, 'width must be at least 16 pixels') if dimensions.width < 16
    errors.add(:image_file, 'height must be at least 16 pixels') if dimensions.height < 16
  rescue
    errors.add(:image_file, 'unable to extract image dimensions')
  end

end

#rotateObject

Returns the value of attribute rotate.



62
63
64
# File 'app/models/image.rb', line 62

def rotate
  @rotate
end

#user_file_nameString

The name of the file as uploaded by the user.

Returns:

  • (String)


49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
# File 'app/models/image.rb', line 49

class Image < ApplicationRecord
  include Housekeeping
  include Shared::Identifiers
  include Shared::Notes
  include Shared::Tags
  include Shared::ProtocolRelationships
  include Shared::Citations
  include Shared::Attributions
  include Shared::IsData
  include SoftValidation

  include Image::Sled

  attr_accessor :rotate

  # ANY non-blank? value here will attempt to also create a depiction 
  # for the Image, linking it to a CollectionObject
  attr_accessor :filename_depicts_object

  MISSING_IMAGE_PATH = '/public/images/missing.jpg'.freeze

  GRAPH_ENTRY_POINTS = [:depictions]

  DEFAULT_SIZES = {
    thumb: { width: 100, height: 100 },
    medium: { width: 300, height: 300 }
  }.freeze

  has_one :sled_image, dependent: :destroy, inverse_of: :image

  has_many :depictions, inverse_of: :image, dependent: :restrict_with_error

  has_many :collection_objects, through: :depictions, source: :depiction_object, source_type: 'CollectionObject'
  has_many :otus, through: :depictions, source: :depiction_object, source_type: 'Otu'
  has_many :taxon_names, through: :otus

  after_validation :stub_depiction, if: Proc.new {|n| !n.filename_depicts_object.blank?}
  before_save :extract_tw_attributes

  # also using https://github.com/teeparham/paperclip-meta
  has_attached_file :image_file,
    styles: {
      thumb: [ "#{DEFAULT_SIZES[:thumb][:width]}x#{DEFAULT_SIZES[:thumb][:height]}>", :png ] ,
      medium: [ "#{DEFAULT_SIZES[:medium][:width]}x#{DEFAULT_SIZES[:medium][:height]}>", :jpg ] },
  default_url: MISSING_IMAGE_PATH,
  filename_cleaner: Utilities::CleanseFilename,
  processors: [:rotator]

  #:restricted_characters => /[^A-Za-z0-9\.]/,
  validates_attachment_content_type :image_file, content_type: /\Aimage\/.*\Z/
  validates_attachment_presence :image_file
  validate :image_dimensions_too_short

  validates_uniqueness_of :image_file_fingerprint, scope: :project_id

  accepts_nested_attributes_for :sled_image, allow_destroy: true

  accepts_nested_attributes_for :depictions, allow_destroy: false

  # This is bad and you should feel bad if your digitization workflow uses it.
  def stub_depiction
    identifier = File.basename(
      image_file.queued_for_write[:original].original_filename,
      '.*'
    )

    co = CollectionObject.joins(:identifiers).where(
      identifiers: {cached: identifier},
      project_id: Current.project_id,
    ).first

    if co.nil?
      errors.add(:base, 'filename does not match any known CollectionObject identifier')
      return
    end

    depictions.build(
      depiction_object_id: co.id,
      depiction_object_type: 'CollectionObject',
      by: Current.user_id,
      project_id: Current.project_id
    )
  end

  # Replaces Image.create!
  def self.deduplicate_create(image_params)
    image = Image.new(image_params)

    if i = Image.where(project_id: Current.project_id, image_file_fingerprint: image.image_file_fingerprint).first
      return i
    else
      return image
    end
  end

  def sqed_depiction
    depictions.joins(:sqed_depiction).first&.sqed_depiction
  end

  # TODO:
  # Deprecated, once images are de-duplicated
  #   this will be removed
  # @return [Boolean]
  def has_duplicate?
    Image.where(image_file_fingerprint: self.image_file_fingerprint).count > 1
  end

  # TODO:
  # Deprecated, once images are de-duplicated
  #   this will be removed. No duplicate images can now be created
  # @return [Array]
  def duplicate_images
    Image.where(image_file_fingerprint: self.image_file_fingerprint).not_self(self).to_a
  end

  # @return [Hash]
  # returns a hash of EXIF data if present, empty hash if not.do
  # EXIF data tags/specifications -  http://web.archive.org/web/20131018091152/http://exif.org/Exif2-2.PDF
  def exif
    ret_val = {} # return value

    unless self.new_record? # only process if record exists
      tmp     = `identify -format "%[EXIF:*]" #{self.image_file.url}` # returns a string (exif:tag=value\n)
      # following removes the exif, spits and recombines string as a hash
      ret_val = tmp.split("\n").collect { |b| b.gsub('exif:', '').split('=') }
        .inject({}) { |hsh, c| hsh.merge(c[0] => c[1]) }
      # might be able to tmp.split("\n").collect { |b|
      # b.gsub("exif:", "").split("=")
      # }.inject(ret_val) { |hsh, c|
      #   hsh.merge(c[0] => c[1])
      # }
    end

    ret_val # return
  end

  # TODO: move to /lib
  # @return [Nil]
  #  currently handling this client side
  def gps_data
    # if there is EXIF data, pulls out geographic coordinates & returns hash of lat/long in decimal degrees
    # (5 digits after decimal point if available)
    # EXIF gps information is in http://web.archive.org/web/20131018091152/http://exif.org/Exif2-2.PDF section 4.6.6
    # note that cameras follow specifications, but EXIF data can be edited manually and may not follow specifications.

    # check if gps data is in d m s (could be edited manually)
    #   => format dd/1,mm/1,ss/1 or dd/1,mmmm/100,0/1 or 40/1, 5/1, 314437/10000
    # N = +
    # S = -
    # E = +
    # W = -
    # Altitude should be based on reference of sea level
    # GPSAltitudeRef is 0 for above sea level, and 1 for below sea level

    # From discussion with Jim -
    # create a utility library called "GeoConvert" and define single method
    # that will convert from degrees min sec to decimal degree
    # - maybe 2 versions? - one returns string, other decimal?
  end

  # Returns the true, unscaled height/width ratio
  # @return [Float]
  def hw_ratio
    raise if height.nil? || width.nil? # if they are something has gone badly wrong
    return (height.to_f / width.to_f)
  end

  # rubocop:disable Style/StringHashKeys
  # used in ImageHelper#image_thumb_tag
  # asthetic scaling of very narrow images in thumbnails
  # @return [Hash]
  def thumb_scaler
    a = self.hw_ratio
    if a < 0.6
      { 'width' => 200, 'height' => 200 * a}
    else
      {}
    end
  end
  # rubocop:enable Style/StringHashKeys

  # the scale factor is typically the same except in a few cases where we skew small thumbs
  # @param [Symbol] size
  # @return [Float]
  def width_scale_for_size(size = :medium)
    (width_for_size(size).to_f / width.to_f)
  end

  # @param [Symbol] size
  # @return [Float]
  def height_scale_for_size(size = :medium)
    height_for_size(size).to_f / height.to_f
  end

  # @param [Symbol] size
  # @return [Float]
  def width_for_size(size = :medium)
    a = self.hw_ratio
    case size
    when :thumb
      a < 0.6 ? 200.0 : ((width.to_f / height.to_f ) * 160.0)
    when :medium
      a < 1 ? 640.0 : 640.0 / a
    when :big
      a < 1 ? 1600.0 : 1600.0 / a
    when :original
      width
    else
      nil
    end
  end

  # @param [Symbol] size
  # @return [Float]
  def height_for_size(size = :medium)
    a = self.hw_ratio
    case size
    when :thumb
      a < 0.6 ? 213.0 * height.to_f / width.to_f : 160
    when :medium
      a < 1 ? a * 640 : 640
    when :big
      a < 1 ? a * 1600 : 1600
    when :original
      height
    else
      nil
    end
  end

  #  def filename(layout_section_type)
  #    'tmp/' + tempfile(layout_section_type).path.split('/').last
  #  end

  #  def tempfile(layout_section_type)
  #    tempfile = Tempfile.new([layout_section_type.to_s, '.jpg'], "#{Rails.root.to_s}/public/images/tmp", encoding: 'ASCII-8BIT' )
  #    tempfile.write(zoomed_image(layout_section_type).to_blob)
  #    tempfile
  #  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image]
  def self.cropped(params)
    image = Image.find(params[:id])
    img = Magick::Image.read(image.image_file.path(:original)).first
    begin
      # img.crop(x, y, width, height, true)
      cropped = img.crop( params[:x].to_i, params[:y].to_i, params[:width].to_i, params[:height].to_i, true)
    rescue RuntimeError
      cropped = img.crop(0,0, 1, 1)  # return a single pixel on error ! TODO: make/return an error image
    ensure
      img.destroy!
    end
    cropped
  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image]
  def self.resized(params)
    c = cropped(params)
    resized = c.resize(params[:new_width].to_i, params[:new_height].to_i) #.sharpen(0x1)
    c.destroy!
    resized
  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image, nil]
  def self.scaled_to_box(params)
    return nil if params[:box_width].to_f == 0 || params[:box_height].to_f == 0
    begin
      c = cropped(params)
      ratio = c.columns.to_f / c.rows.to_f
      box_ratio = params[:box_width].to_f / params[:box_height].to_f
      # TODO: special considerations for 1:1?

      if box_ratio > 1
        if ratio > 1 # wide into wide
          scaled = c.resize(
            params[:box_width].to_i,
            (params[:box_height].to_f / ratio * box_ratio).to_i
          ) #.sharpen(0x1)
        else # tall into wide
          scaled = c.resize(
            (params[:box_width ].to_f * ratio / box_ratio).to_i,
            params[:box_height].to_i
          ) #.sharpen(0x1)
        end
      else # <
        if ratio > 1 # wide into tall
          scaled = c.resize(
            params[:box_width].to_i,
            (params[:box_height].to_f / ratio * box_ratio).to_i
          ) #.sharpen(0x1)
        else # tall into tall # TODO: or 1:1?!
          scaled = c.resize(
            (params[:box_width].to_f * ratio / box_ratio ).to_i,
            (params[:box_height].to_f ).to_i
          ) #.sharpen(0x1)
        end
      end
      c.destroy!
    rescue Magick::ImageMagickError
      nil
    end
    scaled
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.scaled_to_box_blob(params)
    self.to_blob!(scaled_to_box(params))
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.resized_blob(params)
    self.to_blob!(resized(params))
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.cropped_blob(params)
    self.to_blob!(cropped(params))
  end

  # @param used_on [String] required, a depictable base class name like  `Otu`, `Content`, or `CollectionObject`
  # @return [Scope]
  #   the max 10 most recently used images, as `used_on`
  def self.used_recently(user_id, project_id, used_on = '')
    i = arel_table
    d = Depiction.arel_table

    # i is a select manager
    j = d.project(d['image_id'], d['updated_at'], d['depiction_object_type']).from(d)
      .where(d['updated_at'].gt( 1.week.ago ))
      .where(d['updated_by_id'].eq(user_id))
      .where(d['project_id'].eq(project_id))
      .order(d['updated_at'].desc)

    z = j.as('recent_i')

    k = Arel::Nodes::InnerJoin.new(z, Arel::Nodes::On.new(
      z['image_id'].eq(i['id']).and(z['depiction_object_type'].eq(used_on))
    ))

    joins(k).distinct.pluck(:id)
  end

  # @params target [String] required, one of nil, `AssertedDistribution`, `Content`, `BiologicalAssociation`, 'TaxonDetermination'
  # @return [Hash] images optimized for user selection
  def self.select_optimized(user_id, project_id, target = nil)
    r = used_recently(user_id, project_id, target)
    h = {
      quick: [],
      pinboard: Image.pinned_by(user_id).where(project_id:).to_a,
      recent: []
    }

    if target && !r.empty?
      h[:recent] = (
        Image.where('"images"."id" IN (?)', r.first(5) ).to_a +
        Image.where(project_id:, created_by_id: user_id, created_at: 3.hours.ago..Time.now)
        .order('updated_at DESC')
        .limit(3).to_a
      ).uniq.sort{|a,b| a.updated_at <=> b.updated_at}

      h[:quick] = (
        Image.pinned_by(user_id).pinboard_inserted.where(project_id:).to_a +
        Image.where('"images"."id" IN (?)', r.first(4) ).to_a)
        .uniq.sort{|a,b| a.updated_at <=> b.updated_at}
    else
      h[:recent] = Image.where(project_id:).order('updated_at DESC').limit(10).to_a
      h[:quick] = Image.pinned_by(user_id).pinboard_inserted.where(pinboard_items: {project_id:}).order('updated_at DESC')
    end

    h
  end

  protected

  # @return [Integer, Nil]
  def extract_tw_attributes
    # NOTE: assumes content type is an image.
    tempfile = image_file.queued_for_write[:original]
    if tempfile
      self.user_file_name = tempfile.original_filename
      geometry = Paperclip::Geometry.from_file(tempfile)
      self.width = geometry.width.to_i
      self.height = geometry.height.to_i
    end
  end

  private

  # Converts image to blob and releases memory of img (image cannot be used afterwards)
  # @param [Magick::Image] img
  # @return [String] a JPG representation of the image
  #   !! Always converts to .jpg, this may need abstraction later
  #   Returns an empty string if no image
  def self.to_blob!(img)
    return '' if img.nil?
    img.format = 'jpg'
    blob = img.to_blob
    img.destroy!
    blob
  end

  def image_dimensions_too_short
    return unless original = image_file.queued_for_write[:original]

    dimensions = Paperclip::Geometry.from_file(original)

    errors.add(:image_file, 'width must be at least 16 pixels') if dimensions.width < 16
    errors.add(:image_file, 'height must be at least 16 pixels') if dimensions.height < 16
  rescue
    errors.add(:image_file, 'unable to extract image dimensions')
  end

end

#widthInteger

Returns the width of the source image in px.

Returns:

  • (Integer)

    the width of the source image in px



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
# File 'app/models/image.rb', line 49

class Image < ApplicationRecord
  include Housekeeping
  include Shared::Identifiers
  include Shared::Notes
  include Shared::Tags
  include Shared::ProtocolRelationships
  include Shared::Citations
  include Shared::Attributions
  include Shared::IsData
  include SoftValidation

  include Image::Sled

  attr_accessor :rotate

  # ANY non-blank? value here will attempt to also create a depiction 
  # for the Image, linking it to a CollectionObject
  attr_accessor :filename_depicts_object

  MISSING_IMAGE_PATH = '/public/images/missing.jpg'.freeze

  GRAPH_ENTRY_POINTS = [:depictions]

  DEFAULT_SIZES = {
    thumb: { width: 100, height: 100 },
    medium: { width: 300, height: 300 }
  }.freeze

  has_one :sled_image, dependent: :destroy, inverse_of: :image

  has_many :depictions, inverse_of: :image, dependent: :restrict_with_error

  has_many :collection_objects, through: :depictions, source: :depiction_object, source_type: 'CollectionObject'
  has_many :otus, through: :depictions, source: :depiction_object, source_type: 'Otu'
  has_many :taxon_names, through: :otus

  after_validation :stub_depiction, if: Proc.new {|n| !n.filename_depicts_object.blank?}
  before_save :extract_tw_attributes

  # also using https://github.com/teeparham/paperclip-meta
  has_attached_file :image_file,
    styles: {
      thumb: [ "#{DEFAULT_SIZES[:thumb][:width]}x#{DEFAULT_SIZES[:thumb][:height]}>", :png ] ,
      medium: [ "#{DEFAULT_SIZES[:medium][:width]}x#{DEFAULT_SIZES[:medium][:height]}>", :jpg ] },
  default_url: MISSING_IMAGE_PATH,
  filename_cleaner: Utilities::CleanseFilename,
  processors: [:rotator]

  #:restricted_characters => /[^A-Za-z0-9\.]/,
  validates_attachment_content_type :image_file, content_type: /\Aimage\/.*\Z/
  validates_attachment_presence :image_file
  validate :image_dimensions_too_short

  validates_uniqueness_of :image_file_fingerprint, scope: :project_id

  accepts_nested_attributes_for :sled_image, allow_destroy: true

  accepts_nested_attributes_for :depictions, allow_destroy: false

  # This is bad and you should feel bad if your digitization workflow uses it.
  def stub_depiction
    identifier = File.basename(
      image_file.queued_for_write[:original].original_filename,
      '.*'
    )

    co = CollectionObject.joins(:identifiers).where(
      identifiers: {cached: identifier},
      project_id: Current.project_id,
    ).first

    if co.nil?
      errors.add(:base, 'filename does not match any known CollectionObject identifier')
      return
    end

    depictions.build(
      depiction_object_id: co.id,
      depiction_object_type: 'CollectionObject',
      by: Current.user_id,
      project_id: Current.project_id
    )
  end

  # Replaces Image.create!
  def self.deduplicate_create(image_params)
    image = Image.new(image_params)

    if i = Image.where(project_id: Current.project_id, image_file_fingerprint: image.image_file_fingerprint).first
      return i
    else
      return image
    end
  end

  def sqed_depiction
    depictions.joins(:sqed_depiction).first&.sqed_depiction
  end

  # TODO:
  # Deprecated, once images are de-duplicated
  #   this will be removed
  # @return [Boolean]
  def has_duplicate?
    Image.where(image_file_fingerprint: self.image_file_fingerprint).count > 1
  end

  # TODO:
  # Deprecated, once images are de-duplicated
  #   this will be removed. No duplicate images can now be created
  # @return [Array]
  def duplicate_images
    Image.where(image_file_fingerprint: self.image_file_fingerprint).not_self(self).to_a
  end

  # @return [Hash]
  # returns a hash of EXIF data if present, empty hash if not.do
  # EXIF data tags/specifications -  http://web.archive.org/web/20131018091152/http://exif.org/Exif2-2.PDF
  def exif
    ret_val = {} # return value

    unless self.new_record? # only process if record exists
      tmp     = `identify -format "%[EXIF:*]" #{self.image_file.url}` # returns a string (exif:tag=value\n)
      # following removes the exif, spits and recombines string as a hash
      ret_val = tmp.split("\n").collect { |b| b.gsub('exif:', '').split('=') }
        .inject({}) { |hsh, c| hsh.merge(c[0] => c[1]) }
      # might be able to tmp.split("\n").collect { |b|
      # b.gsub("exif:", "").split("=")
      # }.inject(ret_val) { |hsh, c|
      #   hsh.merge(c[0] => c[1])
      # }
    end

    ret_val # return
  end

  # TODO: move to /lib
  # @return [Nil]
  #  currently handling this client side
  def gps_data
    # if there is EXIF data, pulls out geographic coordinates & returns hash of lat/long in decimal degrees
    # (5 digits after decimal point if available)
    # EXIF gps information is in http://web.archive.org/web/20131018091152/http://exif.org/Exif2-2.PDF section 4.6.6
    # note that cameras follow specifications, but EXIF data can be edited manually and may not follow specifications.

    # check if gps data is in d m s (could be edited manually)
    #   => format dd/1,mm/1,ss/1 or dd/1,mmmm/100,0/1 or 40/1, 5/1, 314437/10000
    # N = +
    # S = -
    # E = +
    # W = -
    # Altitude should be based on reference of sea level
    # GPSAltitudeRef is 0 for above sea level, and 1 for below sea level

    # From discussion with Jim -
    # create a utility library called "GeoConvert" and define single method
    # that will convert from degrees min sec to decimal degree
    # - maybe 2 versions? - one returns string, other decimal?
  end

  # Returns the true, unscaled height/width ratio
  # @return [Float]
  def hw_ratio
    raise if height.nil? || width.nil? # if they are something has gone badly wrong
    return (height.to_f / width.to_f)
  end

  # rubocop:disable Style/StringHashKeys
  # used in ImageHelper#image_thumb_tag
  # asthetic scaling of very narrow images in thumbnails
  # @return [Hash]
  def thumb_scaler
    a = self.hw_ratio
    if a < 0.6
      { 'width' => 200, 'height' => 200 * a}
    else
      {}
    end
  end
  # rubocop:enable Style/StringHashKeys

  # the scale factor is typically the same except in a few cases where we skew small thumbs
  # @param [Symbol] size
  # @return [Float]
  def width_scale_for_size(size = :medium)
    (width_for_size(size).to_f / width.to_f)
  end

  # @param [Symbol] size
  # @return [Float]
  def height_scale_for_size(size = :medium)
    height_for_size(size).to_f / height.to_f
  end

  # @param [Symbol] size
  # @return [Float]
  def width_for_size(size = :medium)
    a = self.hw_ratio
    case size
    when :thumb
      a < 0.6 ? 200.0 : ((width.to_f / height.to_f ) * 160.0)
    when :medium
      a < 1 ? 640.0 : 640.0 / a
    when :big
      a < 1 ? 1600.0 : 1600.0 / a
    when :original
      width
    else
      nil
    end
  end

  # @param [Symbol] size
  # @return [Float]
  def height_for_size(size = :medium)
    a = self.hw_ratio
    case size
    when :thumb
      a < 0.6 ? 213.0 * height.to_f / width.to_f : 160
    when :medium
      a < 1 ? a * 640 : 640
    when :big
      a < 1 ? a * 1600 : 1600
    when :original
      height
    else
      nil
    end
  end

  #  def filename(layout_section_type)
  #    'tmp/' + tempfile(layout_section_type).path.split('/').last
  #  end

  #  def tempfile(layout_section_type)
  #    tempfile = Tempfile.new([layout_section_type.to_s, '.jpg'], "#{Rails.root.to_s}/public/images/tmp", encoding: 'ASCII-8BIT' )
  #    tempfile.write(zoomed_image(layout_section_type).to_blob)
  #    tempfile
  #  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image]
  def self.cropped(params)
    image = Image.find(params[:id])
    img = Magick::Image.read(image.image_file.path(:original)).first
    begin
      # img.crop(x, y, width, height, true)
      cropped = img.crop( params[:x].to_i, params[:y].to_i, params[:width].to_i, params[:height].to_i, true)
    rescue RuntimeError
      cropped = img.crop(0,0, 1, 1)  # return a single pixel on error ! TODO: make/return an error image
    ensure
      img.destroy!
    end
    cropped
  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image]
  def self.resized(params)
    c = cropped(params)
    resized = c.resize(params[:new_width].to_i, params[:new_height].to_i) #.sharpen(0x1)
    c.destroy!
    resized
  end

  # @param [ActionController::Parameters] params
  # @return [Magick::Image, nil]
  def self.scaled_to_box(params)
    return nil if params[:box_width].to_f == 0 || params[:box_height].to_f == 0
    begin
      c = cropped(params)
      ratio = c.columns.to_f / c.rows.to_f
      box_ratio = params[:box_width].to_f / params[:box_height].to_f
      # TODO: special considerations for 1:1?

      if box_ratio > 1
        if ratio > 1 # wide into wide
          scaled = c.resize(
            params[:box_width].to_i,
            (params[:box_height].to_f / ratio * box_ratio).to_i
          ) #.sharpen(0x1)
        else # tall into wide
          scaled = c.resize(
            (params[:box_width ].to_f * ratio / box_ratio).to_i,
            params[:box_height].to_i
          ) #.sharpen(0x1)
        end
      else # <
        if ratio > 1 # wide into tall
          scaled = c.resize(
            params[:box_width].to_i,
            (params[:box_height].to_f / ratio * box_ratio).to_i
          ) #.sharpen(0x1)
        else # tall into tall # TODO: or 1:1?!
          scaled = c.resize(
            (params[:box_width].to_f * ratio / box_ratio ).to_i,
            (params[:box_height].to_f ).to_i
          ) #.sharpen(0x1)
        end
      end
      c.destroy!
    rescue Magick::ImageMagickError
      nil
    end
    scaled
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.scaled_to_box_blob(params)
    self.to_blob!(scaled_to_box(params))
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.resized_blob(params)
    self.to_blob!(resized(params))
  end

  # @param [ActionController::Parameters] params
  # @return [String]
  def self.cropped_blob(params)
    self.to_blob!(cropped(params))
  end

  # @param used_on [String] required, a depictable base class name like  `Otu`, `Content`, or `CollectionObject`
  # @return [Scope]
  #   the max 10 most recently used images, as `used_on`
  def self.used_recently(user_id, project_id, used_on = '')
    i = arel_table
    d = Depiction.arel_table

    # i is a select manager
    j = d.project(d['image_id'], d['updated_at'], d['depiction_object_type']).from(d)
      .where(d['updated_at'].gt( 1.week.ago ))
      .where(d['updated_by_id'].eq(user_id))
      .where(d['project_id'].eq(project_id))
      .order(d['updated_at'].desc)

    z = j.as('recent_i')

    k = Arel::Nodes::InnerJoin.new(z, Arel::Nodes::On.new(
      z['image_id'].eq(i['id']).and(z['depiction_object_type'].eq(used_on))
    ))

    joins(k).distinct.pluck(:id)
  end

  # @params target [String] required, one of nil, `AssertedDistribution`, `Content`, `BiologicalAssociation`, 'TaxonDetermination'
  # @return [Hash] images optimized for user selection
  def self.select_optimized(user_id, project_id, target = nil)
    r = used_recently(user_id, project_id, target)
    h = {
      quick: [],
      pinboard: Image.pinned_by(user_id).where(project_id:).to_a,
      recent: []
    }

    if target && !r.empty?
      h[:recent] = (
        Image.where('"images"."id" IN (?)', r.first(5) ).to_a +
        Image.where(project_id:, created_by_id: user_id, created_at: 3.hours.ago..Time.now)
        .order('updated_at DESC')
        .limit(3).to_a
      ).uniq.sort{|a,b| a.updated_at <=> b.updated_at}

      h[:quick] = (
        Image.pinned_by(user_id).pinboard_inserted.where(project_id:).to_a +
        Image.where('"images"."id" IN (?)', r.first(4) ).to_a)
        .uniq.sort{|a,b| a.updated_at <=> b.updated_at}
    else
      h[:recent] = Image.where(project_id:).order('updated_at DESC').limit(10).to_a
      h[:quick] = Image.pinned_by(user_id).pinboard_inserted.where(pinboard_items: {project_id:}).order('updated_at DESC')
    end

    h
  end

  protected

  # @return [Integer, Nil]
  def extract_tw_attributes
    # NOTE: assumes content type is an image.
    tempfile = image_file.queued_for_write[:original]
    if tempfile
      self.user_file_name = tempfile.original_filename
      geometry = Paperclip::Geometry.from_file(tempfile)
      self.width = geometry.width.to_i
      self.height = geometry.height.to_i
    end
  end

  private

  # Converts image to blob and releases memory of img (image cannot be used afterwards)
  # @param [Magick::Image] img
  # @return [String] a JPG representation of the image
  #   !! Always converts to .jpg, this may need abstraction later
  #   Returns an empty string if no image
  def self.to_blob!(img)
    return '' if img.nil?
    img.format = 'jpg'
    blob = img.to_blob
    img.destroy!
    blob
  end

  def image_dimensions_too_short
    return unless original = image_file.queued_for_write[:original]

    dimensions = Paperclip::Geometry.from_file(original)

    errors.add(:image_file, 'width must be at least 16 pixels') if dimensions.width < 16
    errors.add(:image_file, 'height must be at least 16 pixels') if dimensions.height < 16
  rescue
    errors.add(:image_file, 'unable to extract image dimensions')
  end

end

Class Method Details

.cropped(params) ⇒ Magick::Image

Parameters:

  • params (ActionController::Parameters)

Returns:

  • (Magick::Image)


291
292
293
294
295
296
297
298
299
300
301
302
303
# File 'app/models/image.rb', line 291

def self.cropped(params)
  image = Image.find(params[:id])
  img = Magick::Image.read(image.image_file.path(:original)).first
  begin
    # img.crop(x, y, width, height, true)
    cropped = img.crop( params[:x].to_i, params[:y].to_i, params[:width].to_i, params[:height].to_i, true)
  rescue RuntimeError
    cropped = img.crop(0,0, 1, 1)  # return a single pixel on error ! TODO: make/return an error image
  ensure
    img.destroy!
  end
  cropped
end

.cropped_blob(params) ⇒ String

Parameters:

  • params (ActionController::Parameters)

Returns:

  • (String)


370
371
372
# File 'app/models/image.rb', line 370

def self.cropped_blob(params)
  self.to_blob!(cropped(params))
end

.deduplicate_create(image_params) ⇒ Object

Replaces Image.create!



134
135
136
137
138
139
140
141
142
# File 'app/models/image.rb', line 134

def self.deduplicate_create(image_params)
  image = Image.new(image_params)

  if i = Image.where(project_id: Current.project_id, image_file_fingerprint: image.image_file_fingerprint).first
    return i
  else
    return image
  end
end

.resized(params) ⇒ Magick::Image

Parameters:

  • params (ActionController::Parameters)

Returns:

  • (Magick::Image)


307
308
309
310
311
312
# File 'app/models/image.rb', line 307

def self.resized(params)
  c = cropped(params)
  resized = c.resize(params[:new_width].to_i, params[:new_height].to_i) #.sharpen(0x1)
  c.destroy!
  resized
end

.resized_blob(params) ⇒ String

Parameters:

  • params (ActionController::Parameters)

Returns:

  • (String)


364
365
366
# File 'app/models/image.rb', line 364

def self.resized_blob(params)
  self.to_blob!(resized(params))
end

.scaled_to_box(params) ⇒ Magick::Image?

Parameters:

  • params (ActionController::Parameters)

Returns:

  • (Magick::Image, nil)


316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
# File 'app/models/image.rb', line 316

def self.scaled_to_box(params)
  return nil if params[:box_width].to_f == 0 || params[:box_height].to_f == 0
  begin
    c = cropped(params)
    ratio = c.columns.to_f / c.rows.to_f
    box_ratio = params[:box_width].to_f / params[:box_height].to_f
    # TODO: special considerations for 1:1?

    if box_ratio > 1
      if ratio > 1 # wide into wide
        scaled = c.resize(
          params[:box_width].to_i,
          (params[:box_height].to_f / ratio * box_ratio).to_i
        ) #.sharpen(0x1)
      else # tall into wide
        scaled = c.resize(
          (params[:box_width ].to_f * ratio / box_ratio).to_i,
          params[:box_height].to_i
        ) #.sharpen(0x1)
      end
    else # <
      if ratio > 1 # wide into tall
        scaled = c.resize(
          params[:box_width].to_i,
          (params[:box_height].to_f / ratio * box_ratio).to_i
        ) #.sharpen(0x1)
      else # tall into tall # TODO: or 1:1?!
        scaled = c.resize(
          (params[:box_width].to_f * ratio / box_ratio ).to_i,
          (params[:box_height].to_f ).to_i
        ) #.sharpen(0x1)
      end
    end
    c.destroy!
  rescue Magick::ImageMagickError
    nil
  end
  scaled
end

.scaled_to_box_blob(params) ⇒ String

Parameters:

  • params (ActionController::Parameters)

Returns:

  • (String)


358
359
360
# File 'app/models/image.rb', line 358

def self.scaled_to_box_blob(params)
  self.to_blob!(scaled_to_box(params))
end

.select_optimized(user_id, project_id, target = nil) ⇒ Hash

Returns images optimized for user selection.

Returns:

  • (Hash)

    images optimized for user selection



399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
# File 'app/models/image.rb', line 399

def self.select_optimized(user_id, project_id, target = nil)
  r = used_recently(user_id, project_id, target)
  h = {
    quick: [],
    pinboard: Image.pinned_by(user_id).where(project_id:).to_a,
    recent: []
  }

  if target && !r.empty?
    h[:recent] = (
      Image.where('"images"."id" IN (?)', r.first(5) ).to_a +
      Image.where(project_id:, created_by_id: user_id, created_at: 3.hours.ago..Time.now)
      .order('updated_at DESC')
      .limit(3).to_a
    ).uniq.sort{|a,b| a.updated_at <=> b.updated_at}

    h[:quick] = (
      Image.pinned_by(user_id).pinboard_inserted.where(project_id:).to_a +
      Image.where('"images"."id" IN (?)', r.first(4) ).to_a)
      .uniq.sort{|a,b| a.updated_at <=> b.updated_at}
  else
    h[:recent] = Image.where(project_id:).order('updated_at DESC').limit(10).to_a
    h[:quick] = Image.pinned_by(user_id).pinboard_inserted.where(pinboard_items: {project_id:}).order('updated_at DESC')
  end

  h
end

.to_blob!(img) ⇒ String (private)

Converts image to blob and releases memory of img (image cannot be used afterwards)

Parameters:

  • img (Magick::Image)

Returns:

  • (String)

    a JPG representation of the image !! Always converts to .jpg, this may need abstraction later Returns an empty string if no image



448
449
450
451
452
453
454
# File 'app/models/image.rb', line 448

def self.to_blob!(img)
  return '' if img.nil?
  img.format = 'jpg'
  blob = img.to_blob
  img.destroy!
  blob
end

.used_recently(user_id, project_id, used_on = '') ⇒ Scope

Returns the max 10 most recently used images, as ‘used_on`.

Parameters:

  • used_on (String) (defaults to: '')

    required, a depictable base class name like ‘Otu`, `Content`, or `CollectionObject`

Returns:

  • (Scope)

    the max 10 most recently used images, as ‘used_on`



377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
# File 'app/models/image.rb', line 377

def self.used_recently(user_id, project_id, used_on = '')
  i = arel_table
  d = Depiction.arel_table

  # i is a select manager
  j = d.project(d['image_id'], d['updated_at'], d['depiction_object_type']).from(d)
    .where(d['updated_at'].gt( 1.week.ago ))
    .where(d['updated_by_id'].eq(user_id))
    .where(d['project_id'].eq(project_id))
    .order(d['updated_at'].desc)

  z = j.as('recent_i')

  k = Arel::Nodes::InnerJoin.new(z, Arel::Nodes::On.new(
    z['image_id'].eq(i['id']).and(z['depiction_object_type'].eq(used_on))
  ))

  joins(k).distinct.pluck(:id)
end

Instance Method Details

#duplicate_imagesArray

TODO: Deprecated, once images are de-duplicated

this will be removed. No duplicate images can now be created

Returns:

  • (Array)


160
161
162
# File 'app/models/image.rb', line 160

def duplicate_images
  Image.where(image_file_fingerprint: self.image_file_fingerprint).not_self(self).to_a
end

#exifHash

returns a hash of EXIF data if present, empty hash if not.do EXIF data tags/specifications - web.archive.org/web/20131018091152/http://exif.org/Exif2-2.PDF

Returns:

  • (Hash)


167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'app/models/image.rb', line 167

def exif
  ret_val = {} # return value

  unless self.new_record? # only process if record exists
    tmp     = `identify -format "%[EXIF:*]" #{self.image_file.url}` # returns a string (exif:tag=value\n)
    # following removes the exif, spits and recombines string as a hash
    ret_val = tmp.split("\n").collect { |b| b.gsub('exif:', '').split('=') }
      .inject({}) { |hsh, c| hsh.merge(c[0] => c[1]) }
    # might be able to tmp.split("\n").collect { |b|
    # b.gsub("exif:", "").split("=")
    # }.inject(ret_val) { |hsh, c|
    #   hsh.merge(c[0] => c[1])
    # }
  end

  ret_val # return
end

#extract_tw_attributesInteger, Nil (protected)

Returns:

  • (Integer, Nil)


430
431
432
433
434
435
436
437
438
439
# File 'app/models/image.rb', line 430

def extract_tw_attributes
  # NOTE: assumes content type is an image.
  tempfile = image_file.queued_for_write[:original]
  if tempfile
    self.user_file_name = tempfile.original_filename
    geometry = Paperclip::Geometry.from_file(tempfile)
    self.width = geometry.width.to_i
    self.height = geometry.height.to_i
  end
end

#gps_dataNil

TODO: move to /lib

Returns:

  • (Nil)

    currently handling this client side



188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'app/models/image.rb', line 188

def gps_data
  # if there is EXIF data, pulls out geographic coordinates & returns hash of lat/long in decimal degrees
  # (5 digits after decimal point if available)
  # EXIF gps information is in http://web.archive.org/web/20131018091152/http://exif.org/Exif2-2.PDF section 4.6.6
  # note that cameras follow specifications, but EXIF data can be edited manually and may not follow specifications.

  # check if gps data is in d m s (could be edited manually)
  #   => format dd/1,mm/1,ss/1 or dd/1,mmmm/100,0/1 or 40/1, 5/1, 314437/10000
  # N = +
  # S = -
  # E = +
  # W = -
  # Altitude should be based on reference of sea level
  # GPSAltitudeRef is 0 for above sea level, and 1 for below sea level

  # From discussion with Jim -
  # create a utility library called "GeoConvert" and define single method
  # that will convert from degrees min sec to decimal degree
  # - maybe 2 versions? - one returns string, other decimal?
end

#has_duplicate?Boolean

TODO: Deprecated, once images are de-duplicated

this will be removed

Returns:

  • (Boolean)


152
153
154
# File 'app/models/image.rb', line 152

def has_duplicate?
  Image.where(image_file_fingerprint: self.image_file_fingerprint).count > 1
end

#height_for_size(size = :medium) ⇒ Float

Parameters:

  • size (Symbol) (defaults to: :medium)

Returns:

  • (Float)


263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
# File 'app/models/image.rb', line 263

def height_for_size(size = :medium)
  a = self.hw_ratio
  case size
  when :thumb
    a < 0.6 ? 213.0 * height.to_f / width.to_f : 160
  when :medium
    a < 1 ? a * 640 : 640
  when :big
    a < 1 ? a * 1600 : 1600
  when :original
    height
  else
    nil
  end
end

#height_scale_for_size(size = :medium) ⇒ Float

Parameters:

  • size (Symbol) (defaults to: :medium)

Returns:

  • (Float)


239
240
241
# File 'app/models/image.rb', line 239

def height_scale_for_size(size = :medium)
  height_for_size(size).to_f / height.to_f
end

#hw_ratioFloat

Returns the true, unscaled height/width ratio

Returns:

  • (Float)


211
212
213
214
# File 'app/models/image.rb', line 211

def hw_ratio
  raise if height.nil? || width.nil? # if they are something has gone badly wrong
  return (height.to_f / width.to_f)
end

#image_dimensions_too_shortObject (private)



456
457
458
459
460
461
462
463
464
465
# File 'app/models/image.rb', line 456

def image_dimensions_too_short
  return unless original = image_file.queued_for_write[:original]

  dimensions = Paperclip::Geometry.from_file(original)

  errors.add(:image_file, 'width must be at least 16 pixels') if dimensions.width < 16
  errors.add(:image_file, 'height must be at least 16 pixels') if dimensions.height < 16
rescue
  errors.add(:image_file, 'unable to extract image dimensions')
end

#sqed_depictionObject



144
145
146
# File 'app/models/image.rb', line 144

def sqed_depiction
  depictions.joins(:sqed_depiction).first&.sqed_depiction
end

#stub_depictionObject

This is bad and you should feel bad if your digitization workflow uses it.



109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'app/models/image.rb', line 109

def stub_depiction
  identifier = File.basename(
    image_file.queued_for_write[:original].original_filename,
    '.*'
  )

  co = CollectionObject.joins(:identifiers).where(
    identifiers: {cached: identifier},
    project_id: Current.project_id,
  ).first

  if co.nil?
    errors.add(:base, 'filename does not match any known CollectionObject identifier')
    return
  end

  depictions.build(
    depiction_object_id: co.id,
    depiction_object_type: 'CollectionObject',
    by: Current.user_id,
    project_id: Current.project_id
  )
end

#thumb_scalerHash

rubocop:disable Style/StringHashKeys used in ImageHelper#image_thumb_tag asthetic scaling of very narrow images in thumbnails

Returns:

  • (Hash)


220
221
222
223
224
225
226
227
# File 'app/models/image.rb', line 220

def thumb_scaler
  a = self.hw_ratio
  if a < 0.6
    { 'width' => 200, 'height' => 200 * a}
  else
    {}
  end
end

#width_for_size(size = :medium) ⇒ Float

Parameters:

  • size (Symbol) (defaults to: :medium)

Returns:

  • (Float)


245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
# File 'app/models/image.rb', line 245

def width_for_size(size = :medium)
  a = self.hw_ratio
  case size
  when :thumb
    a < 0.6 ? 200.0 : ((width.to_f / height.to_f ) * 160.0)
  when :medium
    a < 1 ? 640.0 : 640.0 / a
  when :big
    a < 1 ? 1600.0 : 1600.0 / a
  when :original
    width
  else
    nil
  end
end

#width_scale_for_size(size = :medium) ⇒ Float

the scale factor is typically the same except in a few cases where we skew small thumbs

Parameters:

  • size (Symbol) (defaults to: :medium)

Returns:

  • (Float)


233
234
235
# File 'app/models/image.rb', line 233

def width_scale_for_size(size = :medium)
  (width_for_size(size).to_f / width.to_f)
end