Всем привет. Недавно я решил отсканировать старые семейные фотографии. Их у меня довольно много – только с первого захода я отобрал более трехсот штук, и это только малая часть от общего числа. Сканировать я, конечно, решил пачками по несколько штук (столько, сколько в сканер влезет). Сделав порядка 100 сканов, я был озадачен. Типичный скан выглядел как то так:
Разрезать вручную всё это количество сканов было безрадостной перспективой и я решил написать небольшой скрипт, который сделает это всё за меня.. В качестве инструмента для обработки я выбрал LinqPad. У меня было несколько причин, почему именно LinqPad - потому, что периодически работал на нетбуке и ставить там какую либо настоящую IDE нецелесообразно
- потому, что в итоге программа всё равно будет небольшой
- потому, что я недавно приобрел лицензию на него и захотелось использовать его в деле
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;
Сканировал я в формат 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: }
На результате видно, что помимо самих фотографий, присутствуют некоторые шумы. Эти шумы мне очень помешают при определении границы фотографии. И далее я покажу, как с этим справиться. Итак, у нас имеется черно-белая фотография, размерами с оригинал, где для каждого пикселя прописано, далеко он находится от цвета фона или нет. Далее, я решил разделить черно белое изображение на пересекающиеся квадраты. Так сказать, огрубить его немного что ли. Почему именно квадраты? Потому, что все фотографии прямоугольные, а значит квадраты чудесно подойдут, чтобы очертить границы каждого фото. Как я выбираю квадрат:
- Беру пиксель, который ещё не принадлежит ни одному квадрату
- Создаю квадрат с этот пиксель
- Увеличиваю ширину и высоту квадрата до тех пор, пока пиксели, которые добавляются квадрату в результате увеличения, на 99% состоят из черного цвета.
- Если квадрат больше увеличить не получается, то проверяю его размеры. Если он меньше, чем 50х50, то просто его отбрасываю. Если больше, добавляю к списку найденных квадратов.
Как видно, размеры наших фотографий были примерно угаданы, хоть и с небольшими погрешностями. Так как это домашние фотографии, то погрешности не страшны. Что делаем дальше. Дальше, нужно объединять рядом стоящие (или перекрывающиеся) квадраты в прямоугольники. Делается это довольно просто.
Получив результирующие прямоугольники, вырезать их из изображения дело совсем тривиальное.
Статистически этот метод, в принципе, работает неплохо. Из 100 сканов всего пара ошибок – и то только потому, что при сканировании я фотографии положил сильно близко друг к другу и между ними совсем не было фона.
Результат выглядит неплохо. По поводу скорости – сотня сканов была обработана примерно за 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: }
Выглядит это так:
- выбираем файлы
- указываем каталог назначения
- ждем
- профит
Чем мне LinqPad понравился. Помимо всех плюшек, что описаны на сайте, при написании данного скрипта мне понравилось то, что я мог в любой момент, не переключаясь на другие программы, увидеть не только состояние любой переменной, но и состояние изображения.
В итоге сотня сканов превращается в несколько сотен фотографий. Что в планах. Возможно, я допишу какой-нибудь нехитрый алгоритм, который будет определять ориентацию фотографии и поворачивать автоматически, так как сейчас этого нет. Также можно оптимизировать (или просто выкинуть и новый написать) алгоритм нахождения границ фотографий. Полностью весь исходник можно скачать тут.
Конечно, я не изобрел ничего нового, и есть для этих целей уже готовые решения. Разрезать скан можно и фотошопом, и другими программами. Но мне было просто интересно написать таки этот функционал самому.
На этом всё. Всем спасибо за внимание и продуктивного программинга.
Комментариев нет:
Отправить комментарий