Пишем аналог Paint на Objective-C

в 11:05, , рубрики: objective-c, paint, Блог компании Everyday Tools, обработка изображений, разработка мобильных приложений, разработка под iOS, фоторедактор

В этой статье мы хотели бы предложить вниманию читателей пошаговый алгоритм создания простого фоторедактора, который может оказаться полезными для приложений, предполагающих взаимодействие пользователя с изображениями. Он будет включать в себя самые базовые функции, давая возможность вносить небольшие корректировки: затереть определенную область (например, ник), выделить важный текст (телефон, адрес или просто цитату), обвести какую либо область на карте или «нарисовать слово». С технической точки зрения, тема довольно избитая, но мы нашли способ модифицировать процесс, сделав его быстрее и проще — возможно, кто-то возьмет на заметку.

Пишем аналог Paint на Objective-C - 1


Любую кривую можно разложить на последовательность взаимосвязанных линий. Тогда отрисовка кривой может осуществляться по следующему простому алгоритму:

  1. Разобьем кривую на последовательность отрезков
  2. Ставим перо на начало первого отрезка
  3. Рисуем линию до конечной точки
  4. Переходим к следующему отрезку
  5. Выбираем цвет, толщину линии и прочие настройки
  6. Выводим линию на экран
  7. Повторяем перечисленные действия для каждого отрезка из последовательности

Чтобы реализовать данный алгоритма потребуется создать класс PaintLine, который будет содержать начальные и конечные точки. Рисование линии осуществляется при помощи компонента UIBezierPath.

Для реализации алгоритма рисования необходимо создать и добавить custom view. Этот компонент будет перехватывать события касания и производить закраску изображения.

Первое прикосновения пальца к экрану задает начальную точку для первой линии:

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [super touchesBegan:touches withEvent:event];
    NSSet *allTouches = [event allTouches];
    if (allTouches.count != 1){
        return;
    }
    UITouch *touch = [[allTouches allObjects] objectAtIndex:0];
    pointTo = [touch locationInView:self];
}

Изменение положение пальца приводит к цепному созданию линий и добавлению каждой созданной линии в последовательность для отображения:

- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [super touchesMoved:touches withEvent:event];
    NSSet *allTouches = [event allTouches];
    if (allTouches.count != 1){
        [linePaint removeAllObjects];
        [self setNeedsDisplay];
        return;
    }
    UITouch *touch = [[allTouches allObjects] objectAtIndex:0];
    pointFrom = pointTo;
    pointTo = [touch locationInView:self];
    if (pointTo.y < self.bounds.size.height && pointFrom.y < self.bounds.size.height) {
        [self addLineFrom:pointFrom to:pointTo];
    }
}

Когда пользователь отрывает палец от экрана, кривая переводится из предварительного просмотра непосредственно на само изображение:

- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [super touchesEnded:touches withEvent:event];
    NSSet *allTouches = [event allTouches];
    if (allTouches.count != 1){
        [linePaint removeAllObjects];
        [self setNeedsDisplay];
        return;
    }
    UITouch *touch = [[allTouches allObjects] objectAtIndex:0];
    pointFrom = pointTo;
    pointTo = [touch locationInView:self];
    if (pointTo.y < self.bounds.size.height && pointFrom.y < self.bounds.size.height) {
        [self addLineFrom:pointFrom to:pointTo];
    }
    imgForPaint = [self getImage:imgForPaint];
    dispatch_async(dispatch_get_main_queue(), ^{
        if (_imageAfterEndPaint) {
            _imageAfterEndPaint(imgForPaint);
        }
    });
}

Результат работы алгоритма имеет один небольшой недостаток. Если выбрать толстую линию, то концы отрезка оказываются перпендикулярны по отношению к направлению начала и конца кривой соответственно. Выглядит это не совсем красиво и достоверно, учитывая, что для рисования выбирается инструмент круглой формы.

Можно рисовать вместо линии круги. Если плавно провести пальцем по экрану телефона, получается неплохой результат, но резкое движение приводит к рельефности линии или даже появлению разрывов.

Чтобы компенсировать недостатки обоих методов, для рисования одной линии можно использовать заполненную фигуру, как изображено на рисунке:

Пишем аналог Paint на Objective-C - 2

В результате получается непрерывная кривая с закругленными концами.

- (UIBezierPath*)getBezuerPathWith:(float)zoom {
    UIBezierPath *bezierPath = [[UIBezierPath alloc] init];
    float width = zoom * _lineWidth / 2.;
    for (PaintLine *line in linePaint) {
        CGPoint point1 = CGPointApplyAffineTransform(line.point1, CGAffineTransformMakeScale(zoom, zoom));
        CGPoint point2 = CGPointApplyAffineTransform(line.point2, CGAffineTransformMakeScale(zoom, zoom));
        [bezierPath moveToPoint:point1];
        float alf = atan2(point2.y - point1.y, point2.x - point1.x);
        [bezierPath addArcWithCenter:point1 radius:width startAngle:alf + M_PI_2 endAngle:alf - M_PI_2 clockwise:YES];
        
        float alf0 = alf - M_PI_2;
        CGPoint point = CGPointMake(cos(alf0) * width + point2.x, sin(alf0) * width + point2.y);
        [bezierPath addLineToPoint:point];
        
        [bezierPath addArcWithCenter:point2 radius:width startAngle:alf - M_PI_2 endAngle:alf + M_PI_2 clockwise:YES];
        alf0 = alf + M_PI_2;
        point = CGPointMake(cos(alf0) * width + point1.x, sin(alf0) * width + point1.y);
        [bezierPath addLineToPoint:point];
    }
    return bezierPath;
}

Переходим к закраске изображения. Она осуществляется при помощи изменения цвета пикселя следующим образом:

  1. Перевести изображение в массив пикселей
  2. Поменять текст для всех пикселей, которым это необходимо
  3. Перейти к координате нужного пикселя и поменять 32 бита цвета в формате ARGB
  4. Собрать изображение из преобразованного массива

Для заливки области можно воспользоваться быстрым методом с оптимизацией количества вызовов стека или очереди. Но если выводить промежуточный результат работы медленного метода заливки, то получается красивый эффект разливания. Чтобы его достичь, необходимо выполнить следующие шаги:

1. Получить цвет пикселя у изображения и сохранить его как цвет для замены
2. Проверить, совпадает ли цвет для замены с новым цветом пикселя. Если нет:

а) Поместить пиксель в очередь
б) Перекрасить выбранный пиксель в новый цвет
в) Повторять, пока очередь не опустеет
г) Вытащить пиксель из очереди

3. Проверить процедуру проверки значения цвета для каждого из соседних пикселей
4. Если цвет граничащего пикселя равен цвету пикселя для замены:

а) Заменить цвет пикселя на новый цвет
б) Поместить пиксель в конец очереди

В зависимости от того, как выбираются соседние пиксели, можно получить различные формы распространения заливки:

1. Заливка по 4 соседним точкам

Пишем аналог Paint на Objective-C - 3

2. Заливка по 8 соседним точкам

Пишем аналог Paint на Objective-C - 4

3. Чередование заливки по 4 и 8 соседним точкам (2 — по четырем, 1 — по восьми)

Пишем аналог Paint на Objective-C - 5

Все эти способы приводят к полигональному распространению заливки. При этом представленные варианты не соответствуют естественной схеме распространения жидкости по ровной поверхности. Для получения эффекта распространения заливки по окружности в алгоритм необходимо внести следующие изменения:

1. Получить цвет пикселя у изображения и сохранить его как цвет для замены
2. Если цвет для замены не совпадает с новым цветом пикселя:

а) Поместить пиксель в очередь
б) Перекрасить выбранный пиксель в новый цвет
в) Установить значение радиуса окружности равное 1
г) Установить количество пикселей, которое необходимое обработать для перехода к следующему радиусу
д) Повторять, пока очередь не опустеет
е) Вытащить пиксель из очереди
ж) Уменьшить на 1 количество пикселей, которое необходимое обработать для перехода к следующему радиусу

3. Проверить процедуру проверки значения цвета для каждого из соседних пикселей
4. Если цвет граничащего пикселя равен цвету пикселя для замены:

а) Заменить цвет пикселя на новый цвет
б) Поместить пиксель в конец очереди

5. Если количество пикселей, которое необходимое обработать для перехода к следующему радиусу, уменьшилось до 0:

а) Установить в качестве нового значение число, равное текущему размеру очереди
б) Увеличить на 1 радиус окружности.

Путем изменения равенства при сравнении цвета для замены и нового цвета на диапазон можно получить плавный переход между заполняемой областью и границей заполнения:

[colorQueue addObject:[NSValue valueWithCGPoint:newStartPoint]];
        
int offset = 4*((w*round(newStartPoint.y))+round(newStartPoint.x)) + 1;
memcpy(colorFroUpdate, &data[offset], 3);
        
float limit = 10;
isCanPaint = !(abs(newColorArray[0] - data[offset]) < limit && abs(newColorArray[1] - data[offset + 1]) < limit && abs(newColorArray[2] - data[offset + 2]) < limit);
        
NSInteger countPixelICurrentIterrations = 1;
int iterrationIndex = 1;
        
while (isCanPaint && colorQueue.count > 0) {
	CGPoint point = [[colorQueue objectAtIndex:0] CGPointValue];
	[colorQueue removeObjectAtIndex:0];
	countPixelICurrentIterrations--;
	offset = 4*((w*round(point.y))+round(point.x)) + 1;
	memcpy(&data[offset], newColorArray, 3);
	CGPoint newPoint;
	int x0 = point.x - 1;
	int x1 = point.x + 1;
	int y0 = point.y - 1;
	int y1 = point.y + 1;
	for (int x = x0; x <= x1; x++) {
		for (int y = y0; y <= y1; y++) {
			float s = sqrtf((x - newStartPoint.x) * (x - newStartPoint.x) + (y - newStartPoint.y) * (y - newStartPoint.y));
			if (s < iterrationIndex + 1) {
				newPoint = CGPointMake(x, y);
				if (newPoint.x >= 0 && newPoint.x < w && newPoint.y >= 0 && newPoint.y < h) {
					offset = 4*((w*round(newPoint.y))+round(newPoint.x)) + 1;
					if (abs(colorFroUpdate[0] - data[offset]) < limit && abs(colorFroUpdate[1] - data[offset + 1]) < limit && abs(colorFroUpdate[2] - data[offset + 2]) < limit) {
						memcpy(&data[offset], newColorArray, 3);
						[colorQueue addObject:[NSValue valueWithCGPoint:newPoint]];
					}
				}
			}
		}
	}
            
	if (countPixelICurrentIterrations <= 0 && self.updateImageOn) {
			if (iterrationIndex % 5 == 0)
			@autoreleasepool {
				CGImageRef cgImage = CGBitmapContextCreateImage(cgctx);
				UIImage *resultUIImage = [UIImage imageWithCGImage:cgImage];
				self.updateImageOn(resultUIImage);
				CGImageRelease(cgImage);
			}
                
			countPixelICurrentIterrations = [colorQueue count];
			iterrationIndex++;
			}
	}
}

Запустив процесс рисования асинхронно, можно наблюдать следующий эффект распространения заливки:

Пишем аналог Paint на Objective-C - 6

Итак, мы реализовали намеченный функционал и попутно продемонстрировали, как простые, а иногда и неэффективные на первый взгляд алгоритмы могут давать очень неожиданный результат. Спасибо за внимание!

Автор: Everyday Tools

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js