воскресенье, 1 сентября 2013 г.

Обработка изображений с помощью…. LinqPad!

Всем привет. Недавно я решил отсканировать старые семейные фотографии. Их у меня довольно много – только с первого захода я отобрал более трехсот штук, и это только малая часть от общего числа. Сканировать я, конечно, решил пачками по несколько штук (столько, сколько в сканер влезет). Сделав порядка 100 сканов, я был озадачен. Типичный скан выглядел как то так:
Scan-130820-0034
Разрезать вручную всё это количество сканов было безрадостной перспективой и я решил написать небольшой скрипт, который сделает это всё за меня.. В качестве инструмента для обработки я выбрал LinqPad. У меня было несколько причин, почему именно LinqPad
  • потому, что периодически работал на нетбуке и ставить там какую либо настоящую IDE нецелесообразно
  • потому, что в итоге программа всё равно будет небольшой
  • потому, что я недавно приобрел лицензию на него и захотелось использовать его в деле Улыбка
Итак, что у нас есть на входе. Около ста сканированных документов. imageЧто характерно для каждого документа? Конечно то, что все они состоят из фотографий, расположенных на однородном фоне. Так как фотографии состоят из огромного количества цветов, а фон – это светлые тона, я решил, что первым делом найду самый часто используемый цвет на фотографии и он будет принадлежать именно фону. Это предположение имеет смысл, если количество фона в скане достаточное, чтобы количество пикселей цвета фона было больше, чем каждого из оставшихся цветов. Итак, что я сделал. Я нашел самый часто используемый цвет
   1:  var image = new Bitmap(fname);    
   2:  // Расширяющая функция - преобразует картинку в массив целых чисел
   3:  var rgb = image.ToRGB();        
   4:  // Самый часто используемый цвет
   5:  var c = rgb.GroupBy(x=>x).Select(x=>new {c=Color.FromArgb(x.Key), n=x.Count()}).OrderByDescending(x=>x.n).First().c;    
Однако, фон ведь на самом деле состоит и некоторой градации цветов, а не из одного цвета. Потому, мне надо было решить, каким образом я буду определять, принадлежит ли определенный пиксель фону или нет. Далее, поскольку цвет состоит из нескольких компонент (альфа канал, красный, зеленый и синий) – я решил сделать просто. Установил границу, скажем 15. Если красная, синяя и зеленая составляющие цвета данного пикселя находятся на расстоянии не больше 15 единиц от цвета фона, то данный пиксель тоже принадлежит фону. (15 я просто установил опытным путем).
Сканировал я в формат JPEG с глубиной 32 бита, то есть по 8 бит на каждый цветовой канал. Отсюда следует, что значение каждого канала варьируется от 0 до 255. Это надо учитывать при определении расстояния до цвета границы.
Следующим моим шагом было выделение массива типа bool[], равного по размеру массиву пикселей исходной фотографии. В нем я решил хранить информацию о каждом пикселе – принадлежит он фону или нет. Выглядит это очень просто.
   1:  var po = 15;        
   2:  var w = image.Width;
   3:  var h = image.Height;
   4:  var nrgb = new bool[w*h];
   5:      
   6:  for(var i = 0; i<w; i++)
   7:  {
   8:      for(var j=0; j<h; j++)
   9:      {            
  10:          var color = Color.FromArgb(rgb[i+j*w]);            
  11:          var dr = Math.Abs(c.R-color.R);
  12:          var dg = Math.Abs(c.G-color.G);
  13:          var db = Math.Abs(c.B-color.B);
  14:          if (dr < po && dg < po && db < po)
  15:          {
  16:              nrgb[i+j*w] = false;
  17:          }
  18:          else
  19:          {
  20:              nrgb[i+j*w] = true;
  21:          }                    
  22:      }
  23:  }
Понимаю, что за названия переменных меня надо расстрелять, но я писал код для себя и потому над именами переменных просто не задумывался. Теперь, если полученный массив преобразовать в черно-белую картинку, то получим вот что (исходник и результат):

Scan-130820-0034Scan-130820-0034-_1
На результате видно, что помимо самих фотографий, присутствуют некоторые шумы. Эти шумы мне очень помешают при определении границы фотографии. И далее я покажу, как с этим справиться. Итак, у нас имеется черно-белая фотография, размерами с оригинал, где для каждого пикселя прописано, далеко он находится от цвета фона или нет. Далее, я решил разделить черно белое изображение на пересекающиеся квадраты. Так сказать, огрубить его немного что ли. Почему именно квадраты? Потому, что все фотографии прямоугольные, а значит квадраты чудесно подойдут, чтобы очертить границы каждого фото. Как я выбираю квадрат:
  • Беру пиксель, который ещё не принадлежит ни одному квадрату
  • Создаю квадрат с этот пиксель
  • Увеличиваю ширину и высоту квадрата до тех пор, пока пиксели, которые добавляются квадрату в результате увеличения, на 99% состоят из черного цвета.
  • Если квадрат больше увеличить не получается, то проверяю его размеры. Если он меньше, чем 50х50, то просто его отбрасываю. Если больше, добавляю к списку найденных квадратов.
Да что глаголить, вот результат:
Scan-130820-0034-_1Scan-130820-0034-_2
Как видно, размеры наших фотографий были примерно угаданы, хоть и с небольшими погрешностями. Так как это домашние фотографии, то погрешности не страшны. Что делаем дальше. Дальше, нужно объединять рядом стоящие (или перекрывающиеся) квадраты в прямоугольники. Делается это довольно просто.
Scan-130820-0034-_2Scan-130820-0034-_3
Получив результирующие прямоугольники, вырезать их из изображения дело совсем тривиальное.
Scan-130820-0034-_0Scan-130820-0034-_3
Статистически этот метод, в принципе, работает неплохо. Из 100 сканов всего пара ошибок – и то только потому, что при сканировании я фотографии положил сильно близко друг к другу и между ними совсем не было фона.
image
Результат выглядит неплохо. По поводу скорости – сотня сканов была обработана примерно за 15 минут.

Так, а причем тут LinqPad?

Теперь перейдем к коду. Во-первых, нам понадобится класс прямоугольника.  Я бы мог использовать уже готовую структуру Rectangle, но так как реализация его тривиальна, да и мне нужно было напичкать его своими методами, я таки решил написать просто свою реализацию.
   1:  public class Rect
   2:  {
   3:      public int X0{get;set;}
   4:      public int Y0{get;set;}
   5:      public int X1{get;set;}
   6:      public int Y1{get;set;}
   7:      
   8:      public int Color{get; private set;}
   9:      
  10:      public int Width{get{return X1-X0;}}
  11:      public int Height{get{return Y1-Y0;}}
  12:      
  13:      public long Square{get{return Width*Height;}}
  14:      
  15:      // если true - то мы ищем области с true, иначе ищем области с false
  16:      public bool Value{get;set;}
  17:      
  18:      public Rect(int x0, int y0, int x1, int y1, bool value, int color)
  19:      {
  20:          X0=x0;
  21:          Y0=y0;
  22:          X1=x1;
  23:          Y1=y1;        
  24:          Value = value;        
  25:          Color=color;
  26:      }
  27:      
  28:      // Определяет, рядом ли находятся (или пересекаются) прямоугольники r1 и r2
  29:      // eps - погрешность
  30:      public static bool Near(Rect r1, Rect r2, int eps)
  31:      {        
  32:          var hres = (int)( (r1.Height+r2.Height)*0.5+eps*2);
  33:          var wres = (int)( (r1.Width+r2.Width)*0.5+eps*2);
  34:          
  35:          var w = Math.Abs(r1.X0 + r1.Width*0.5 - r2.X0 - r2.Width*0.5);
  36:          var h = Math.Abs(r1.Y0 + r1.Height*0.5 - r2.Y0 - r2.Height*0.5);
  37:          
  38:          return hres>h && wres>w;
  39:      }
  40:      
  41:      // Объединяет текущий прямоугольник с тем, что пришел в параметре
  42:      public void Merge(Rect r)
  43:      {
  44:          var x0 = Math.Min(X0, r.X0);
  45:          var y0 = Math.Min(Y0, r.Y0);
  46:          
  47:          var x1 = Math.Max(X1, r.X1);
  48:          var y1 = Math.Max(Y1, r.Y1);
  49:          
  50:          X0 = x0;
  51:          Y0 = y0;
  52:          X1 = x1;
  53:          Y1 = y1;
  54:      }
  55:      
  56:      // Определяет, принадлежит ли точка прямоугольнику
  57:      public bool In (int x, int y)
  58:      {
  59:          return x>=X0 && x<=X1 && y>=Y0 && y<=Y1;
  60:      }
  61:      
  62:      // Увеличивает размер прямоугольника по оси X на inc, только в том случае, если
  63:      // добавленные пиксели на 99% состоят из значений Value
  64:      public bool IncreaseX(bool[] arr, int width, int height, int inc)
  65:      {        
  66:          var matchCount=0;
  67:          int border = (int)((Y1-Y0+1)*0.99);
  68:          
  69:          if ((X1+inc) >= width)return false;
  70:          
  71:          for(var j=Y0; j<=Y1; j++)
  72:          {
  73:              var item = arr[j*width+X1+inc];
  74:              if (item == Value) matchCount++;
  75:              if (matchCount >= border)
  76:              {
  77:                  X1+=inc;
  78:                  return true;
  79:              }
  80:          }
  81:          
  82:          return false;
  83:      }
  84:      
  85:      // Увеличивает размер прямоугольника по оси Y на inc, только в том случае, если
  86:      // добавленные пиксели на 99% состоят из значений Value
  87:      public bool IncreaseY(bool[] arr, int width, int height, int inc)
  88:      {        
  89:          var matchCount=0;
  90:          int border = (int)((X1-X0+1)*0.99);
  91:          
  92:          if ((Y1+inc) >= height)return false;
  93:          
  94:          for(var i=X0; i<=X1; i++)
  95:          {
  96:              var item = arr[(Y1+inc)*width+i];
  97:              if (item == Value) matchCount++;
  98:              if (matchCount >= border)
  99:              {
 100:                  Y1+=inc;
 101:                  return true;
 102:              }
 103:          }
 104:          
 105:          return false;
 106:      }
 107:  }

Таким образом, основная функция для обработки конкретной фотографии выглядит так:
   1:  public static void Process(string fname, string outFolder)
   2:  {
   3:      var image = new Bitmap(fname);
   4:      
   5:      // Расширяющая функция - преобразует картинку в массив целых чисел
   6:      var rgb = image.ToRGB();    
   7:      
   8:      // Самый часто используемый цвет
   9:      var    c = rgb.GroupBy(x=>x).Select(x=>new {c=Color.FromArgb(x.Key), n=x.Count()}).OrderByDescending(x=>x.n).First().c;    
  10:      
  11:      var minPhotoSqare = (int)(image.Width*image.Height*0.01);
  12:      var po = 15;    
  13:      
  14:      var w = image.Width;
  15:      var h = image.Height;
  16:      var nrgb = new bool[w*h];
  17:      
  18:      for(var i = 0; i<w; i++)
  19:      {
  20:          for(var j=0; j<h; j++)
  21:          {            
  22:              var color = Color.FromArgb(rgb[i+j*w]);            
  23:              var dr = Math.Abs(c.R-color.R);
  24:              var dg = Math.Abs(c.G-color.G);
  25:              var db = Math.Abs(c.B-color.B);
  26:              if (dr < po && dg < po && db < po)
  27:              {
  28:                  nrgb[i+j*w] = false;
  29:              }
  30:              else
  31:              {
  32:                  nrgb[i+j*w] = true;
  33:              }                    
  34:          }
  35:      }
  36:      
  37:      // Поиск всех квадратов размерами не менее minrect в массиве
  38:      var rects = nrgb.FindAllRect(w, h);    
  39:      // Ищем и объединяем рядом стоящие прямоугольники
  40:      var dtemp = rects.UnionRect().Where(x=>x.Square>=minPhotoSqare).ToArray();
  41:              
  42:      // Сохраняем найденные фотографии в выходную папку
  43:      for(var i=0; i<dtemp.Length; i++)
  44:      {
  45:          image.Crop(dtemp[i]).Save(
  46:              Path.Combine(outFolder, string.Format(@"{0}-{1}.jpg",Path.GetFileNameWithoutExtension(fname), i)), 
  47:              ImageFormat.Jpeg);    
  48:      }
  49:  }

Но мне этого было мало. Хотелось бы как то запустить скрипт и наблюдать за ходом прогресса. Поскольку LinqPad позволяет с легкостью создавать обычные формы, манипулировать контролами и вообще всё, что и так доступно при программировании под .NET, то я не преминул этим воспользоваться. Я добавил диалоги для выбора исходных файлов сканов, диалог для выбора папки назначения – куда буду писать результат, форму, куда добавил ProgressBar и BackgroundWorker для обработки файлов отдельно от потока формы. Код подучился такой:
   1:  void Main()
   2:  {
   3:      var ofd = new OpenFileDialog();
   4:      ofd.Multiselect = true;
   5:      ofd.Title = "Select files to separate";
   6:      if (ofd.ShowDialog() == DialogResult.OK && ofd.FileNames.Length>0)
   7:      {
   8:          var ofld = new FolderBrowserDialog();
   9:          ofld.ShowNewFolderButton = true;    
  10:          ofld.Description = "Select directory to store separated files";
  11:          if (ofld.ShowDialog() == DialogResult.OK && !string.IsNullOrEmpty(ofld.SelectedPath) && Directory.Exists(ofld.SelectedPath))
  12:          {    
  13:              var files = ofd.FileNames;
  14:              var len = files.Length;
  15:              
  16:              var form = new Form();    
  17:              form.FormBorderStyle = FormBorderStyle.FixedToolWindow;    
  18:              form.Width=0;
  19:              form.Height=0;
  20:              form.TopMost = true;
  21:              
  22:              form.StartPosition = FormStartPosition.CenterScreen;
  23:              var progress = new System.Windows.Forms.ProgressBar();
  24:              
  25:              progress.Maximum = len;
  26:              progress.Minimum = 0;
  27:              progress.Value=0;
  28:              progress.Step=1;
  29:              
  30:              form.Controls.Add(progress);    
  31:              form.AutoSizeMode = AutoSizeMode.GrowAndShrink;
  32:              form.AutoSize = true;                
  33:              form.Show();
  34:              
  35:              var back = new BackgroundWorker();            
  36:              back.WorkerSupportsCancellation = true;
  37:              
  38:              form.FormClosed+= (sender, args) => {back.CancelAsync();};
  39:              
  40:              
  41:              back.DoWork+= (sender, args) => 
  42:              {            
  43:                  for(var i=0; i<len; i++)
  44:                  {
  45:                      try
  46:                      {
  47:                          if (!back.CancellationPending)
  48:                          {
  49:                              var fname = files[i];
  50:                              Process(fname, ofld.SelectedPath);                    
  51:                              progress.PerformStep();
  52:                          }
  53:                      }
  54:                      catch(Exception ex)
  55:                      {
  56:                          form.Close();
  57:                          MessageBox.Show(ex.Message, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
  58:                          ex.Dump();
  59:                          return;
  60:                      }
  61:                  }
  62:                  
  63:                  form.Close();
  64:                  MessageBox.Show("Done", "Done", MessageBoxButtons.OK, MessageBoxIcon.Information);
  65:              };
  66:              
  67:              back.RunWorkerAsync();
  68:          }
  69:      }
  70:  }

Выглядит это так:
  • выбираем файлы
  • указываем каталог назначения
  • ждем
  • профит
imageimageimageimageimage
Чем мне LinqPad понравился. Помимо всех плюшек, что описаны на сайте, при написании данного скрипта мне понравилось то, что я мог в любой момент, не переключаясь на другие программы, увидеть не только состояние любой переменной, но и состояние изображения.
image
В итоге сотня сканов превращается в несколько сотен фотографий. Что в планах. Возможно, я допишу какой-нибудь нехитрый алгоритм, который будет определять ориентацию фотографии и поворачивать автоматически, так как сейчас этого нет. Также можно оптимизировать (или просто выкинуть и новый написать) алгоритм нахождения границ фотографий. Полностью весь исходник можно скачать тут.
Конечно, я не изобрел ничего нового, и есть для этих целей уже готовые решения. Разрезать скан можно и фотошопом, и другими программами. Но мне было просто интересно написать таки этот функционал самому.
На этом всё. Всем спасибо за внимание и продуктивного программинга.

Комментариев нет:

Отправить комментарий