#!/usr/bin/env ruby

# How to check image color or back and white -- http://www.imagemagick.org/discourse-server/viewtopic.php?t=19580
#
#   convert file.jpg -colorspace HSL -verbose info:|grep -A 6 Green # values: absolute (percent)
#       Green:
#         min:  0       (0)
#         max:  255     (1)
#         mean: 70.2043 (0.275311)
#         standard deviation: 99.2055 (0.389041)
#         kurtosis: -0.981812
#         skewness:  0.897517
#
# You convert the image to HSL and then output verbose information ("identify"
# statistics).
# In HSL space Saturation define how colorful pixels are, and this is returned
# in the 'Green' channel statistics.
#
# If max is 0 so no color was present at all: it is a perfect grayscale or black/white image.
# If max is 1 so at least one pure color pixel exists: it is not a pure grayscale image.
# 
# The mean indicates how much colorful in general the image is.
# A mean of 40% means the image is not even mostly grayscale (white background).
#
# Other statistics show how the color ratios was distributed about that mean.
#
# NOTE:
#   Due to HSL being a double hexcone model, this colorspace does not work properly
#   for measuring saturation for L>50% as it approaches the top peak. There white
#   is going to be indetermined saturation and turns out is interpreted as high
#   saturation. In its place, use HCL, HCLp, HSI or HSB above. These are all single
#   hexcone type models and should work fine in the above equation.
#
#   convert file.jpg -colorspace HSI -channel g +channel -format "%[fx:mean.g]" info:
#
# ------------------------------------------------------------------------------
#
# How to retrive the single values from the statistics:
#   Retrieving individual Image Channel Statistics -- http://www.imagemagick.org/discourse-server/viewtopic.php?t=21008
#   FX Expressions as Format and Annotate Escapes  -- http://www.imagemagick.org/Usage/transform/#fx_escapes
#   Format and Print Image Properties              -- http://www.imagemagick.org/script/escape.php
#   The Fx Special Effects Image Operator          -- http://www.imagemagick.org/script/fx.php
#
# ------------------------------------------------------------------------------
# 
# Robust command to get H/S/I statistics in the R/G/B sections:
#   convert file.jpg -colorspace HSB -verbose info: | grep -A 20 Red
#
# Get the single value of a statistic (saturation mean):
#   convert file.jpg -colorspace HSB -channel g +channel -format "%[fx:mean.g]" info:
#
# Get image colors histogram for a restricted number of colors (64):
#   convert file.jpg -dither FloydSteinberg -colors 64 -colorspace HSB -format %c histogram:info:-

$VERBOSE = nil

require 'shellwords'

EXIT_CODES = { colored:              0,
               colored_mid_sat:      1,
               grayscale:            2,
               monochrome:           3,
               monochrome_mid_sat:   4,
               error:              100, }

if ARGV.size == 0
  puts "USAGE: #{File.basename __FILE__} img.ext [-h] [-s] [-q]"
  puts "  FLAGS:"
  puts "    -h    show histograms"
  puts "    -s    show statistics"
  puts "    -q    quiet mode"
  puts "  EXIT CODES:"
  EXIT_CODES.each{|k, n| puts "    #{n.to_s.ljust 3}   #{k}"}
  exit EXIT_CODES[:error]
end

def test_image(fname, opts = {})
  opts = { hist: false, stats: false, quiet: true }.merge opts
  
  unless File.exists?(fname)
    STDERR.puts "ERROR: file not found" unless opts[:quiet]
    return :error
  end

  # computing histogram
  histlines = `convert #{fname.shellescape} -dither FloydSteinberg -colors 64 -colorspace HSB -format %c histogram:info:- 2> /dev/null`.split("\n")
  puts "----- full histogram -----\n#{histlines.join "\n"}" if opts[:hist]
  
  # extract values from histogram (num.pixels, hue, saturation, brightness)
  hist = histlines.grep(/hs[ib]/i).map{|line|
    # http://www.imagemagick.org/Usage/files/#histogram
    # pixel count:    R   G   B  #HEX    hsb(hue, saturation, brightness)
    #        9274: (  1, 89,135) #015987 hsb(0.582895%,35.0103%,53.0617%)
    next unless m = line.match(Regexp.new ' +([0-9]+):.+#([0-9A-F]+) hs[ib]a*\(([0-9\.]+)(%*),([0-9\.]+)%*,([0-9\.]+)%')
    hue = m.captures[2].to_f  # percentage values: 0-100 (old imagemagick version)
    hue = hue/360*100 if m.captures[3].to_s.size == 0 # degrees 0-360 => convert to 0-100
    hue = hue.round(4)
    {
      npix: m.captures[0].to_i,
      hex:  m.captures[1],
      hue:  hue,
      hue2: (hue > 50 ? (hue-50) : (hue+50)).round(4), # 180 degrees shifted HUE
      sat:  m.captures[4].to_f,
      int:  m.captures[5].to_f,
    }
  }.compact
  
  if hist.size < 2
    STDERR.puts "ERROR: histogram size (#{hist.size}) < 2" unless opts[:quiet]
    return :error
  end
  
  # compute saturation mean and unique hue std.deviation
  removed_rows = []
  if opts[:hist]
    puts "----- new histogram -----"
    puts hist[0].keys.map{|i| "#{i}  ".upcase.rjust(10)}.join(" | ")
  end
  stats = hist.each_with_index.inject({
    tot_pixels: 0,
    sat_sum:    0,
    hue_num:    0, hue_mean:  0, hue_m2:  0,
    hue2_num:   0, hue2_mean: 0, hue2_m2: 0,
    hue_outlayered_pixels: 0,
  }){|stats, pair|
    row, i = pair
    
    stats[:tot_pixels] += row[:npix]
    
    stats[:sat_sum] += row[:npix] * row[:sat]
    
    if %w{ 000000 FFFFFF }.include?(row[:hex]) || row[:sat] < 10
      # remove not only the pure black and white colors, but any color that has a
      # very, very low saturation (greys). When the saturation is near zero, the
      # hue has very little meaning, and could point in any direction.
      stats[:hue_outlayered_pixels] += row[:npix]
      removed_rows << row
    else
      # incremental/online formula for std.deviation
      # https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Online_algorithm
      
      # compute hue std.dev
      delta             = row[:hue] - stats[:hue_mean]
      stats[:hue_num ] += 1
      stats[:hue_mean] += delta / stats[:hue_num]
      stats[:hue_m2  ] += delta * (row[:hue] - stats[:hue_mean])
      
      # FIX the red hue that wraps around the 360 dregree:
      # Compute again the std.dev with the hues rotated 180 degress.
      # You will either get roughly the same standard deviation, or wildly different.
      # In the second case, one of the std.dev would probably be very small, indicating
      # an image with a red midtone coloring.
      delta             = row[:hue2] - stats[:hue2_mean]
      stats[:hue2_num ] += 1
      stats[:hue2_mean] += delta / stats[:hue2_num]
      stats[:hue2_m2  ] += delta * (row[:hue2] - stats[:hue2_mean])
  
      puts row.values.map{|i| i.to_s.rjust(10)}.join(" | ") if opts[:hist]
    end
    
    stats
  }#hist.each_with_index.inject
  if opts[:hist]
    puts '   --- deleted ---'
    puts hist[0].keys.map{|i| "#{i}  ".upcase.rjust(10)}.join(" | ")
    puts removed_rows.map{|l| l.values.map{|i| i.to_s.rjust(10)}.join(" | ")}
  end
  
  hist_size = hist.size - removed_rows.size
  sat_mean  = stats[:sat_sum] / stats[:tot_pixels]
  hue_sd    = (stats[:hue_m2 ] / (hist_size - 1)) ** 0.5  if hist_size > 3
  hue2_sd   = (stats[:hue2_m2] / (hist_size - 1)) ** 0.5  if hist_size > 3
  min_hue   = [hue_sd, hue2_sd].min                       if hist_size > 3
  hue_outl  = stats[:hue_outlayered_pixels].to_f / stats[:tot_pixels] * 100
  
  img_type = \
    if    hist_size == 0 ; :grayscale
    elsif hist_size <= 3 ; :monochrome
    elsif sat_mean  < 10 ; :grayscale   # low saturation
    # middle saturation: hue must be big for a colored image
    elsif sat_mean  < 30 ; min_hue > 25 ? :colored_mid_sat : :monochrome_mid_sat
    else                 ; :colored     # high saturation
    end
  
  puts([
    fname.ljust(30),
    'S:%5.2f'  % sat_mean ,
    'H:%5.2f'  % hue_sd .to_f,
    'H2:%5.2f' % hue2_sd.to_f,
    'N:%2d'    % hist.size,
    'R:%2d'    % removed_rows.size,
    'O:%3d%%'  % hue_outl ,
    '=>  ',
    img_type,
  ].join('  ')) if opts[:stats]
  
  puts img_type unless opts[:quiet] || opts[:stats]
  
  img_type
end # test_image ---------------------------------------------------------------

img_type = test_image ARGV[0], \
  hist:   ARGV.include?('-h'),
  quiet:  ARGV.include?('-q'),
  stats:  ARGV.include?('-s')

exit EXIT_CODES[img_type]

# ls *.jpg | sort | while read fname; do ../img-test.rb "$fname"; done
