Статьи

Swift Tone Curve Editor



Одной из приятных функций моего
приложения Nodality является небольшой
виджет для редактирования точек кривой тона . Кривая тона изменяет яркость изображения для данного тонального диапазона. Например, увеличение значения самой левой точки кривой делает тени ярче, и, наоборот, уменьшение значения самой правой точки делает светлые участки более темными.
CIToneCurve фильтр является частью
CoreImage и принимает пять точек , которые образуют кривые. В этом блоге рассматривается создание
приложения Swift, которое позволяет пользователю загружать изображение и редактировать кривую тона этого изображения с помощью пяти вертикальных ползунков.

Я построил приложение с двумя основными элементами управления —
ToneCurveEditor,  который содержит вертикальные ползунки, и
ImageWidget, который загружает и отображает изображение и применяет к нему фильтр. Основной
ViewController размещает их обоих.

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


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

     override func viewDidLayoutSubviews()
    {
        let topMargin = Int(topLayoutGuide.length)
        
        if view.frame.size.width < view.frame.size.height
        {
            // portrait mode
            let widgetWidth = Int(view.frame.size.width)
            let widgetHeight = Int(view.frame.size.height) / 2
            
            imageWidget.frame = CGRect(x: 5, y: topMargin, width: widgetWidth - 10, height: widgetHeight - topMargin - topMargin)
            toneCurveEditor.frame = CGRect(x: 0, y: widgetHeight, width: widgetWidth, height: widgetHeight - 50)
            
        }
        else
        {
            // landscape mode
            let widgetWidth = Int(view.frame.size.width) / 2
            let widgetHeight = Int(view.frame.size.height)
            
            imageWidget.frame = CGRect(x: widgetWidth, y: topMargin, width: widgetWidth - 5, height: widgetHeight - topMargin - topMargin)
            toneCurveEditor.frame = CGRect(x: 0, y: 0, width: widgetWidth, height: widgetHeight - 50)
        }
    }

Теперь, когда мое устройство поворачивается, пользовательский интерфейс плавно переключается между двумя макетами. 
ToneCurveEditor содержит пять ползунков , которые устанавливают значения кривых тонов и выдвинутые
CALayer ,
ToneCurveEditorCurveLayer , что делает кривой , соединяющее значение.
Я мог бы вручную создать каждый слайдер, но гораздо лучше создать их в цикле. Это уменьшает репликацию кода и, если, например,
в будущем CIToneCurve изменится для поддержки большего количества точек кривой, все, что мне нужно будет сделать, это изменить верхнюю границу цикла, чтобы создать больше ползунков.
Создание слайдера выполняется внутри
переопределенного
init () ToneCurveEditor

функция. Каждый из них является экземпляром
UISlider, который повернут на 90 °, задан обработчик действия и добавлен как вспомогательное представление:

     func createSliders()
    {
        let rotateTransform = CGAffineTransformIdentity
        
        for i in 0..<5
        {
            let slider = UISlider(frame: CGRectZero)
  
            slider.transform = CGAffineTransformRotate(rotateTransform, CGFloat(-90.0 * M_PI / 180.0));
            slider.addTarget(self, action: "sliderChangeHandler:", forControlEvents: .ValueChanged)
            
            sliders.append(slider)
            
            addSubview(slider)
        }
    }

Если какой — либо слайдер изменился, я регенерировать ToneCurveEditor «s curveValues массив и отправляет событие изменения. Так как у curveValues есть наблюдатель didSet , ToneCurveEditor  перерисовывает фоновую кривую при ее изменении:

     var curveValues : [Double] = [Double](count: 5, repeatedValue: 0.0)
    {
        didSet
        {
            for (i : Int, value : Double) in enumerate(curveValues)
            {
                sliders[i].value = Float(value)
            }
            
            drawCurve() // forces a curveLayer.setNeedsDisplay()
        }
    }

Внизу в ToneCurveEditorCurveLayer я переопределил drawInContext (), чтобы нарисовать набор кривых Безье, чтобы связать точки вместе.

Лучший способ сделать это — использовать
сплайн
Catmull Rom , но у меня не хватило
времени для его реализации, и я делаю это с помощью серии кривых, которые выглядят немного лучше, чем набор прямых линий.
Стоп Пресс: теперь обновлен с помощью сплайна Эрмита, см. Этот блог для получения дополнительной информации.

Это делается путем создания нового UIBezierPath , зацикливания точек кривой и добавления кубических срезов Безье для каждой точки:

for (i: Int, value: Double) in enumerate(curveValues)
{
      let pathPointX = i * (widgetWidth / curveValues.count) + (widgetWidth / curveValues.count / 2)
      let pathPointY = thumbRadius + margin + widgetHeight - Int(Double(widgetHeight) * value)
                
      if i == 0
      {
             previousPoint = CGPoint(x: pathPointX,y: pathPointY)
                    
             path.moveToPoint(previousPoint)
      }
      else
      {
             // TODO - implement as Catmull-Rom
             let currentPoint = CGPoint(x: pathPointX, y: pathPointY)

             let controlPointOne = CGPoint(x: currentPoint.x, y: previousPoint.y)
             let controlPointTwo = CGPoint(x: previousPoint.x, y: currentPoint.y)
                    
             path.addCurveToPoint(CGPoint(x: pathPointX, y: pathPointY), controlPoint1: controlPointOne, controlPoint2: controlPointTwo)
                    
             previousPoint = currentPoint
       }
}

Конечный результат выглядит так:

ImageWidget  содержит
UIButton , который позволяет пользователю загружать и изображения, и
UIImageView , который отображает изображение.

Когда кнопка нажата, она использует вставленную ссылку на свой родительский контроллер представления, чтобы вызвать
presentViewController () с
UIImagePickerController . Поскольку
ImageWidget реализует
протокол
UIImagePickerControllerDelegate , после того, как пользователь выбрал изображение,
вызывается imagePickerController () .

Я обнаружил, что пытался отфильтровать большие изображения (мой
Fuji XE-1дает 16MP изображений) было мучительно медленно и начал сбой приложения. Итак, я написал довольно простое расширение для
UIImage, которое изменяет размеры загруженного изображения с точностью до определенного ограничивающего квадрата:

 extension UIImage
{
    func resizeToBoundingSquare(#boundingSquareSideLength : CGFloat) -> UIImage
    {
        let imgScale = self.size.width > self.size.height ? boundingSquareSideLength / self.size.width : boundingSquareSideLength / self.size.height
        let newWidth = self.size.width * imgScale
        let newHeight = self.size.height * imgScale
        let newSize = CGSize(width: newWidth, height: newHeight)
        
        UIGraphicsBeginImageContext(newSize)
        
        self.drawInRect(CGRect(x: 0, y: 0, width: newWidth, height: newHeight))
        
        let resizedImage = UIGraphicsGetImageFromCurrentImageContext()
        
        UIGraphicsEndImageContext();
        
        return resizedImage
    }
}

В производственном приложении вы можете использовать этот меньший прокси во время взаимодействия и заменить необработанное изображение для окончательного рендеринга.

Таким образом, 
главная обязанность
imagePickerController () — создать копию необработанного изображения, чтобы он уместился в квадрат 1024 x 1024. При изменении изображения или точек введенной кривой
ImageWidget должен применить фильтр кривой тона к измененному изображению. Чтобы обеспечить отзывчивость пользовательского интерфейса, фильтрация выполняется в фоновом потоке (снова с использованием
библиотеки Async
Тобиаса
). Таким образом, метод экземпляра выглядит следующим образом:

func applyFilterAsync()
    {
        backgroundBlock = Async.background
        {
            if !self.filterIsRunning && self.loadedImage != nil
            {
                self.filterIsRunning = true
                self.filteredImage = ImageWidget.applyFilter(loadedImage: self.loadedImage!, curveValues: self.curveValues, ciContext: self.ciContext, filter: self.filter)
            }
        }
        .main
        {
            self.imageView.image = self.filteredImage
            self.filterIsRunning = false
        }
    }

…. и функция класса, которая выполняет тяжелую работу, выглядит следующим образом:

     class func applyFilter(#loadedImage: UIImage, curveValues: [Double], ciContext: CIContext, filter: CIFilter) -> UIImage
    {
        let coreImage = CIImage(image: loadedImage)
        
        filter.setValue(coreImage, forKey: kCIInputImageKey)
        
        filter.setValue(CIVector(x: 0.0, y: CGFloat(curveValues[0])), forKey: "inputPoint0")
        filter.setValue(CIVector(x: 0.25, y: CGFloat(curveValues[1])), forKey: "inputPoint1")
        filter.setValue(CIVector(x: 0.5, y: CGFloat(curveValues[2])), forKey: "inputPoint2")
        filter.setValue(CIVector(x: 0.75, y: CGFloat(curveValues[3])), forKey: "inputPoint3")
        filter.setValue(CIVector(x: 1.0, y: CGFloat(curveValues[4])), forKey: "inputPoint4")
        
        let filteredImageData = filter.valueForKey(kCIOutputImageKey) as CIImage
        let filteredImageRef = ciContext.createCGImage(filteredImageData, fromRect: filteredImageData.extent())
        let filteredImage = UIImage(CGImage: filteredImageRef)
       
        return filteredImage
    }

И
CIContext, и
CIFilter  являются константами экземпляров, которые нужно создать только один раз, а затем использовать повторно, поэтому я передаю их в
applyFilter () .

Сочетание использования прокси-изображения и фильтрации в фоновом режиме делает приложение очень плавным и отзывчивым.

И это об этом. Как всегда,
исходный код доступен в репозитории myGitHub , наслаждайтесь!