UIImage
auto levels algorithm
image processing
iOS development
Swift

UIImage - implementing an auto levels algorithm

ML System Design practice on Codemia

Design recommenders, ranking systems and training pipelines the way ML interviews actually ask for them, with worked solutions.

Practice ML system design

Introduction

An auto-levels filter improves contrast by remapping pixel intensities so the useful part of the image spans more of the available range. On iOS, you can implement this for a UIImage by reading raw pixels, measuring channel ranges, and stretching the values back into 0...255.

What Auto Levels Actually Does

At a high level, auto levels finds the dark and bright points in an image and rescales the pixels between them. If an image is washed out, its histogram is often compressed into a narrow band, such as values between 60 and 180 instead of the full 0 to 255 range.

The simplest algorithm is:

  1. read every pixel
  2. find the minimum and maximum values for each channel
  3. remap every channel using linear scaling

The formula is:

scaled = (value - min) * 255 / (max - min)

That produces a stronger image, though real photo editors often use clipped percentiles instead of the absolute min and max so that one outlier pixel does not distort the whole result.

Convert UIImage To A Writable Pixel Buffer

To modify pixels directly, you need a CGImage, a bitmap context, and a byte buffer. The code below uses CoreGraphics and UIKit only, which makes it easy to drop into an iOS project.

swift
1import UIKit
2import CoreGraphics
3
4extension UIImage {
5    func autoLevels() -> UIImage? {
6        guard let cgImage = self.cgImage else { return nil }
7
8        let width = cgImage.width
9        let height = cgImage.height
10        let bytesPerPixel = 4
11        let bytesPerRow = width * bytesPerPixel
12        let bitsPerComponent = 8
13
14        var pixels = [UInt8](repeating: 0, count: width * height * bytesPerPixel)
15
16        guard let context = CGContext(
17            data: &pixels,
18            width: width,
19            height: height,
20            bitsPerComponent: bitsPerComponent,
21            bytesPerRow: bytesPerRow,
22            space: CGColorSpaceCreateDeviceRGB(),
23            bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
24        ) else {
25            return nil
26        }
27
28        context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
29
30        let ranges = Self.channelRanges(in: pixels)
31        Self.applyLevels(to: &pixels, ranges: ranges)
32
33        guard let output = context.makeImage() else { return nil }
34        return UIImage(cgImage: output, scale: scale, orientation: imageOrientation)
35    }
36}

At this point you have raw RGBA bytes in memory and can analyze them safely.

Measure Channel Ranges

For a simple auto-levels implementation, scan the red, green, and blue channels separately and record the minimum and maximum values.

swift
1extension UIImage {
2    private static func channelRanges(in pixels: [UInt8]) -> [(min: UInt8, max: UInt8)] {
3        var mins = [UInt8](repeating: 255, count: 3)
4        var maxs = [UInt8](repeating: 0, count: 3)
5
6        stride(from: 0, to: pixels.count, by: 4).forEach { i in
7            for channel in 0..<3 {
8                let value = pixels[i + channel]
9                mins[channel] = min(mins[channel], value)
10                maxs[channel] = max(maxs[channel], value)
11            }
12        }
13
14        return zip(mins, maxs).map { ($0.0, $0.1) }
15    }
16}

You could also compute one range from luminance instead of per-channel ranges. Per-channel scaling often gives a more dramatic result, but it can also shift colors more aggressively.

Remap The Pixels

Once you know the ranges, stretch the channel values. Guard against min == max, because a flat channel cannot be normalized.

swift
1extension UIImage {
2    private static func applyLevels(
3        to pixels: inout [UInt8],
4        ranges: [(min: UInt8, max: UInt8)]
5    ) {
6        stride(from: 0, to: pixels.count, by: 4).forEach { i in
7            for channel in 0..<3 {
8                let minValue = Int(ranges[channel].min)
9                let maxValue = Int(ranges[channel].max)
10
11                guard maxValue > minValue else { continue }
12
13                let value = Int(pixels[i + channel])
14                let scaled = (value - minValue) * 255 / (maxValue - minValue)
15                pixels[i + channel] = UInt8(max(0, min(255, scaled)))
16            }
17        }
18    }
19}

This is the core of the effect. If the original image only used a narrow intensity band, the output will have stronger contrast.

Make The Algorithm More Practical

Absolute min and max values are sensitive to outliers. A single dead-black or pure-white pixel can force the whole image to stretch around it. In production, a better version computes a histogram and clips a small percentage from both ends before scaling.

That approach works like this:

  • build a histogram for each channel
  • ignore the lowest and highest small percentile
  • use the remaining bounds as the new black and white points

If you need high throughput, use Accelerate or Core Image for the histogram stage. If you need a readable first implementation, the manual byte-buffer approach above is easier to understand and debug.

Common Pitfalls

  • Ignoring the alpha channel layout. If your bitmap format does not match the indexing logic, colors will be wrong.
  • Stretching each channel independently without testing skin tones or neutral grays. Strong casts can appear.
  • Using absolute min and max in noisy images. Outliers can make the correction weak.
  • Repeatedly reprocessing the same image. Auto levels is best applied to the original source, not to an already modified result.
  • Forgetting image orientation. Returning a UIImage without preserving orientation metadata can produce rotated output.

Summary

  • Auto levels improves contrast by remapping pixel values into a wider range.
  • A simple implementation scans pixels, records per-channel min and max values, and rescales them.
  • 'CGContext plus a byte buffer is enough for a clear Swift implementation.'
  • Production-quality filters usually clip histogram tails instead of using absolute extremes.
  • Test the effect on real images, because aggressive per-channel stretching can distort color balance.

Related reading
Course
Intermediate
27 lessons
15 hours
DSA Fundamentals

Master algorithmic patterns and data structures through hands-on LeetCode-style problems - from arrays and hashing to dynamic programming and advanced graphs.

View the course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

ML System Design practice on Codemia

Design recommenders, ranking systems and training pipelines the way ML interviews actually ask for them, with worked solutions.

Practice ML system design

All Rights Reserved.