5

I'm a relatively new Swift developer and I am using the CILinearGradient CIFilter to generate gradients that I can then use as backgrounds and textures. I was pretty happy with the way it was working, until I realized that the gradients coming out of it seem to be heavily skewed towards away from the black end of the spectrum.

At first I thought I was nuts, but then I created pure black-to-white and white-to-black gradients and put them on screen next to each other. I took a screenshot and brought it into Photoshop. then I looked at the color values. You can see that the ends of each gradient line up (pure black over pure white on one end, and the opposite on the other), but the halfway point of each gradient is significantly skewed towards the black end.

enter image description here

Is this an issue with the CIFilter or am I doing something wrong? Thanks to anyone with any insight on this!

Here's my code:

func gradient2colorIMG(UIcolor1: UIColor, UIcolor2: UIColor, width: CGFloat, height: CGFloat) -> CGImage? {
    if let gradientFilter = CIFilter(name: "CILinearGradient") {   
        let startVector:CIVector = CIVector(x: 0 + 10, y: 0)
        let endVector:CIVector = CIVector(x: width - 10, y: 0)
        let color1 = CIColor(color: UIcolor1)
        let color2 = CIColor(color: UIcolor2)
        let context = CIContext(options: nil)
        if let currentFilter = CIFilter(name: "CILinearGradient") {
            currentFilter.setValue(startVector, forKey: "inputPoint0")
            currentFilter.setValue(endVector, forKey: "inputPoint1")
            currentFilter.setValue(color1, forKey: "inputColor0")
            currentFilter.setValue(color2, forKey: "inputColor1")
            if let output = currentFilter.outputImage {
                if let cgimg = context.createCGImage(output, from: CGRect(x: 0, y: 0, width: width, height: height)) {
                    let gradImage = cgimg
                    return gradImage
                }
            }
        }
    }
    return nil
}

and then I call it in SpriteKit using this code (but this is just so I can see them on the screen to compare the CGImages that are output by the function) ...

if let gradImage = gradient2colorIMG(UIcolor1: UIColor(red: 255.0 / 255.0, green: 255.0 / 255.0, blue: 255.0 / 255.0, alpha: 1.0), UIcolor2: UIColor(red: 0.0 / 255.0, green: 0.0 / 255.0, blue: 0.0 / 255.0, alpha: 1.0), width: 250, height: 80) {        
    let sampleback = SKShapeNode(path: CGPath(roundedRect: CGRect(x: 0, y: 0, width: 250, height: 80), cornerWidth: 5, cornerHeight: 5, transform: nil))
    sampleback.fillColor = .white
    sampleback.fillTexture = SKTexture(cgImage: gradImage)
    sampleback.zPosition = 200
    sampleback.position = CGPoint(x: 150, y: 50)
    self.addChild(sampleback)
}    
if let gradImage2 = gradient2colorIMG(UIcolor1: UIColor(red: 0.0 / 255.0, green: 0.0 / 255.0, blue: 0.0 / 255.0, alpha: 1.0), UIcolor2: UIColor(red: 255.0 / 255.0, green: 255.0 / 255.0, blue: 255.0 / 255.0, alpha: 1.0), width: 250, height: 80) {    
    let sampleback2 = SKShapeNode(path: CGPath(roundedRect: CGRect(x: 0, y: 0, width: 250, height: 80), cornerWidth: 5, cornerHeight: 5, transform: nil))
    sampleback2.fillColor = .white
    sampleback2.fillTexture = SKTexture(cgImage: gradImage2)
    sampleback2.zPosition = 200
    sampleback2.position = CGPoint(x: 150, y: 150)
    self.addChild(sampleback2)
}

As another follow-up, I tried doing a red-blue gradient (so purely a change in hue) and it is perfectly linear (see below). The issue seems to be around the brightness.

A red-blue gradient DOES ramp its hue in a perfectly linear fashion

Leo Dabus
  • 229,809
  • 59
  • 489
  • 571
Singular20
  • 53
  • 3
  • please edit your question and post a [mcve]. You are creating two contexts in your gradient2colorIMG method. Post your actual code. – Leo Dabus Sep 13 '20 at 02:47
  • Try to define CIVector input points in relative coordinates from 0 to 1 – Eugene Dudnyk Sep 13 '20 at 03:12
  • Thanks @LeoDabus. That extra CGcontext was a mistake left over from an earlier attempt. I hope my edits did what you asked. – Singular20 Sep 13 '20 at 03:23
  • 1
    I'm not sure exactly how it’s determining the distribution of colors, but I find it interesting that the midpoint (using a photoshop eye dropper) of your images above was an RGB value of roughly 188, 188, 188, which looks like the “absolute white” rendition of [middle gray](https://en.wikipedia.org/wiki/Middle_gray). Also note, the translation of RGB values to [relative luminance](https://en.wikipedia.org/wiki/Relative_luminance) is not a simple arithmetic mean of the RGB, but rather 0.2126 * red + 0.7152 * green + 0.0722 * blue. When converting RGB to grayscale, we often use that formula. – Rob Sep 13 '20 at 05:26
  • It's the CIContext color space. I've added an answer demonstrating. – matt Sep 13 '20 at 18:22

2 Answers2

3

Imagine that black is 0 and white is 1. Then the problem here is that we intuitively think that 50% of black "is" a grayscale value of 0.5 — and that is not true.

To see this, consider the following core image experiment:

let con = CIContext(options: nil)
let white = CIFilter(name:"CIConstantColorGenerator")!
white.setValue(CIColor(color:.white), forKey:"inputColor")
let black = CIFilter(name:"CIConstantColorGenerator")!
black.setValue(CIColor(color:UIColor.black.withAlphaComponent(0.5)),
    forKey:"inputColor")
let atop = CIFilter(name:"CISourceAtopCompositing")!
atop.setValue(white.outputImage!, forKey:"inputBackgroundImage")
atop.setValue(black.outputImage!, forKey:"inputImage")
let cgim = con.createCGImage(atop.outputImage!, 
    from: CGRect(x: 0, y: 0, width: 201, height: 50))!
let image = UIImage(cgImage: cgim)
let iv = UIImageView(image:image)
self.view.addSubview(iv)
iv.frame.origin = CGPoint(x: 100, y: 150)

What I've done here is to lay a 50% transparency black swatch on top of a white swatch. We intuitively imagine that the result will be a swatch that will read as 0.5. But it isn't; it's 0.737, the very same shade that is appearing at the midpoint of your gradients:

enter image description here

The reason is that everything here is happening, not in some mathematical vacuum, but in a color space adjusted for a specific gamma.

Now, you may justly ask: "But where did I specify this color space? This is not what I want!" Aha. You specified it in the first line, when you created a CIContext without overriding the default working color space.

Let's fix that. Change the first line to this:

let con = CIContext(options: [.workingColorSpace : NSNull()])

Now the output is this:

enter image description here

Presto, that's your 0.5 gray!

So what I'm saying is, if you create your CIContext like that, you will get the gradient you are after, with 0.5 gray at the midpoint. I'm not saying that that is any more "right" than the result you are getting, but at least it shows how to get that particular result with the code you already have.

(In fact, I think what you were getting originally is more "right", as it is adjusted for human perception.)

matt
  • 515,959
  • 87
  • 875
  • 1,141
  • Thanks! This definitely solves what I was running into. I agree with you about "right" vs "not right" given this information, and knowing where to make this adjustment is what really matters here. – Singular20 Sep 13 '20 at 19:11
  • Right, I'm just trying to explain the phenomenon; I'm not saying to do this, I'm just showing what would happen if you did. – matt Sep 13 '20 at 19:12
2

The midpoint of the CILinearGradient appears to correspond to 188, 188, 188, which looks like the “absolute whiteness” rendition of middle gray, which is not entirely unreasonable. (The CISmoothLinearGradient offers a smoother transition, but it doesn’t have the midpoint at 0.5, 0.5, 0.5, either.) As an aside, the “linear” in CILinearGradient and CISmoothLinearGradient refer to the shape of the gradient (to differentiate it from a “radial” gradient), not the nature of the color transitions within the gradient.

However if you want a gradient whose midpoint is 0.5, 0.5, 0.5, you can use CGGradient:

func simpleGradient(in rect: CGRect) -> UIImage {
    return UIGraphicsImageRenderer(bounds: rect).image { context in
        let colors = [UIColor.white.cgColor, UIColor.black.cgColor]
        let colorSpace = CGColorSpaceCreateDeviceGray() // or RGB works, too
        guard let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: nil) else { return }
        context.cgContext.drawLinearGradient(gradient, start: .zero, end: CGPoint(x: rect.maxX, y: 0), options: [])
    }
}

gradient image


Alternatively, if you want a gradient background, you might define a UIView subclass that uses a CAGradientLayer as its backing layer:

class GradientView: UIView {
    override class var layerClass: AnyClass { return CAGradientLayer.self }
    var gradientLayer: CAGradientLayer { return layer as! CAGradientLayer }

    override init(frame: CGRect = .zero) {
        super.init(frame: frame)
        configure()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        configure()
    }

    func configure() {
        gradientLayer.colors = [UIColor.white.cgColor, UIColor.black.cgColor]
        gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
        gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
    }
}
Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • Not related to the question but didn't you forget to update the gradient on layoutSubviews or traitCollectionDidChange? – Leo Dabus Sep 13 '20 at 13:51
  • @LeoDabus - No, setting the backing layer with `layerClass` eliminates the need for that. In fact this approach is preferable because it is also appropriately rendered throughout animations, too, not jumping from one size to another like the `layoutSubviews` and similar approaches. – Rob Sep 13 '20 at 14:27
  • I did realize that when testing. I have been using traitCollectionDidChange for a while to support the use of dynamic colors. https://stackoverflow.com/questions/24380535/how-to-apply-gradient-to-background-view-of-ios-swift-app/37243106#37243106 – Leo Dabus Sep 13 '20 at 15:07
  • 1
    Yep. Or I use those methods when I have to manually update paths based upon updated bounds, etc. But for simple gradient like this, it’s best to use `layerClass` and set-it-and-forget-it. – Rob Sep 13 '20 at 15:39
  • Unfortunately this doesn't answer the actual question. The question was, what is up with the midpoint of a CIFilter's black-to-white linear gradient not being 0.5? (It was not, how _do_ you make a black-to-white linear gradient whose midpoint is 0.5?) I think you answered the question correctly in the comments, but comments are volatile. So in my view, that is what the answer should have been. I had come to the same conclusion (it has to do with the definition of middle gray) and I think it's an important point that needs to be made. – matt Sep 13 '20 at 16:00
  • IMHO, there is no good answer to the “why” question in the absence of any clear documentation on the `CIFilter` internal algorithms. But I’ve added my “middle gray” supposition to the answer. But hopefully showing how to achieve the desired behavior will be helpful. – Rob Sep 13 '20 at 17:13
  • "As an aside, the “linear” in CILinearGradient and CISmoothLinearGradient refer to the shape of the gradient (to differentiate it from a “radial” gradient), not the nature of the color transitions within the gradient." Well, yes and no. I mean yes, true enough. But the _documentation_ says the _blend_ is linear too: "the CILinearGradient filter blends colors linearly (that is, the color at a point 25% along the line between Point 1 and Point 2 is 25% Color 1 and 75% Color 2)". That is what we are trying to grapple with here. The 50% point is not 50% black and 50% white. – matt Sep 13 '20 at 17:21
  • I've confirmed your 188 value independently by the way. – matt Sep 13 '20 at 17:56
  • I now see what causes the 188 to be used as the midpoint; it's the gamma of the default device RGB color space. I've added an answer explaining this. – matt Sep 13 '20 at 18:22
  • A big thanks to all of you for helping explore this. I appreciate the different suggestions and insights. – Singular20 Sep 13 '20 at 19:14