闭社主体 forked from https://github.com/tootsuite/mastodon
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

191 lines
5.6 KiB

  1. # frozen_string_literal: true
  2. require 'mime/types/columnar'
  3. module Paperclip
  4. class ColorExtractor < Paperclip::Processor
  5. MIN_CONTRAST = 3.0
  6. ACCENT_MIN_CONTRAST = 2.0
  7. FREQUENCY_THRESHOLD = 0.01
  8. def make
  9. depth = 8
  10. # Determine background palette by getting colors close to the image's edge only
  11. background_palette = palette_from_histogram(convert(':source -alpha set -gravity Center -region 75%x75% -fill None -colorize 100% -alpha transparent +region -format %c -colors :quantity -depth :depth histogram:info:', source: File.expand_path(@file.path), quantity: 10, depth: depth), 10)
  12. # Determine foreground palette from the whole image
  13. foreground_palette = palette_from_histogram(convert(':source -format %c -colors :quantity -depth :depth histogram:info:', source: File.expand_path(@file.path), quantity: 10, depth: depth), 10)
  14. background_color = background_palette.first || foreground_palette.first
  15. foreground_colors = []
  16. return @file if background_color.nil?
  17. max_distance = 0
  18. max_distance_color = nil
  19. foreground_palette.each do |color|
  20. distance = ColorDiff.between(background_color, color)
  21. contrast = w3c_contrast(background_color, color)
  22. if distance > max_distance && contrast >= ACCENT_MIN_CONTRAST
  23. max_distance = distance
  24. max_distance_color = color
  25. end
  26. end
  27. foreground_colors << max_distance_color unless max_distance_color.nil?
  28. max_distance = 0
  29. max_distance_color = nil
  30. foreground_palette.each do |color|
  31. distance = ColorDiff.between(background_color, color)
  32. contrast = w3c_contrast(background_color, color)
  33. if distance > max_distance && contrast >= MIN_CONTRAST && !foreground_colors.include?(color)
  34. max_distance = distance
  35. max_distance_color = color
  36. end
  37. end
  38. foreground_colors << max_distance_color unless max_distance_color.nil?
  39. # If we don't have enough colors for accent and foreground, generate
  40. # new ones by manipulating the background color
  41. (2 - foreground_colors.size).times do |i|
  42. foreground_colors << lighten_or_darken(background_color, 35 + (15 * i))
  43. end
  44. # We want the color with the highest contrast to background to be the foreground one,
  45. # and the one with the highest saturation to be the accent one
  46. foreground_color = foreground_colors.max_by { |rgb| w3c_contrast(background_color, rgb) }
  47. accent_color = foreground_colors.max_by { |rgb| rgb_to_hsl(rgb.r, rgb.g, rgb.b)[1] }
  48. meta = {
  49. colors: {
  50. background: rgb_to_hex(background_color),
  51. foreground: rgb_to_hex(foreground_color),
  52. accent: rgb_to_hex(accent_color),
  53. },
  54. }
  55. attachment.instance.file.instance_write(:meta, (attachment.instance.file.instance_read(:meta) || {}).merge(meta))
  56. @file
  57. end
  58. private
  59. def w3c_contrast(color1, color2)
  60. luminance1 = color1.to_xyz.y * 0.01 + 0.05
  61. luminance2 = color2.to_xyz.y * 0.01 + 0.05
  62. if luminance1 > luminance2
  63. luminance1 / luminance2
  64. else
  65. luminance2 / luminance1
  66. end
  67. end
  68. # rubocop:disable Naming/MethodParameterName
  69. def rgb_to_hsl(r, g, b)
  70. r /= 255.0
  71. g /= 255.0
  72. b /= 255.0
  73. max = [r, g, b].max
  74. min = [r, g, b].min
  75. h = (max + min) / 2.0
  76. s = (max + min) / 2.0
  77. l = (max + min) / 2.0
  78. if max == min
  79. h = 0
  80. s = 0 # achromatic
  81. else
  82. d = max - min
  83. s = l >= 0.5 ? d / (2.0 - max - min) : d / (max + min)
  84. case max
  85. when r
  86. h = (g - b) / d + (g < b ? 6.0 : 0)
  87. when g
  88. h = (b - r) / d + 2.0
  89. when b
  90. h = (r - g) / d + 4.0
  91. end
  92. h /= 6.0
  93. end
  94. [(h * 360).round, (s * 100).round, (l * 100).round]
  95. end
  96. def hue_to_rgb(p, q, t)
  97. t += 1 if t.negative?
  98. t -= 1 if t > 1
  99. return (p + (q - p) * 6 * t) if t < 1 / 6.0
  100. return q if t < 1 / 2.0
  101. return (p + (q - p) * (2 / 3.0 - t) * 6) if t < 2 / 3.0
  102. p
  103. end
  104. def hsl_to_rgb(h, s, l)
  105. h /= 360.0
  106. s /= 100.0
  107. l /= 100.0
  108. r = 0.0
  109. g = 0.0
  110. b = 0.0
  111. if s.zero?
  112. r = l.to_f
  113. g = l.to_f
  114. b = l.to_f # achromatic
  115. else
  116. q = l < 0.5 ? l * (1 + s) : l + s - l * s
  117. p = 2 * l - q
  118. r = hue_to_rgb(p, q, h + 1 / 3.0)
  119. g = hue_to_rgb(p, q, h)
  120. b = hue_to_rgb(p, q, h - 1 / 3.0)
  121. end
  122. [(r * 255).round, (g * 255).round, (b * 255).round]
  123. end
  124. # rubocop:enable Naming/MethodParameterName
  125. def lighten_or_darken(color, by)
  126. hue, saturation, light = rgb_to_hsl(color.r, color.g, color.b)
  127. light = begin
  128. if light < 50
  129. [100, light + by].min
  130. else
  131. [0, light - by].max
  132. end
  133. end
  134. ColorDiff::Color::RGB.new(*hsl_to_rgb(hue, saturation, light))
  135. end
  136. def palette_from_histogram(result, quantity)
  137. frequencies = result.scan(/([0-9]+)\:/).flatten.map(&:to_f)
  138. hex_values = result.scan(/\#([0-9A-Fa-f]{6,8})/).flatten
  139. total_frequencies = frequencies.reduce(&:+).to_f
  140. frequencies.map.with_index { |f, i| [f / total_frequencies, hex_values[i]] }
  141. .sort_by { |r| -r[0] }
  142. .reject { |r| r[1].size == 8 && r[1].end_with?('00') }
  143. .map { |r| ColorDiff::Color::RGB.new(*r[1][0..5].scan(/../).map { |c| c.to_i(16) }) }
  144. .slice(0, quantity)
  145. end
  146. def rgb_to_hex(rgb)
  147. '#%02x%02x%02x' % [rgb.r, rgb.g, rgb.b]
  148. end
  149. end
  150. end