Implementing Image Filters in Swift

In this segment, you’ll implement three image processing algorithms in Swift: grayscale conversion, blur effect, and brightness adjustment. You’ll work directly with raw pixel data, learning how to manipulate individual color channels to create visual effects.

Image processing algorithms operate on pixel arrays, transforming color values according to mathematical formulas. Swift’s type safety and array handling make it well-suited for this kind of algorithmic work.

Understanding the ImageProcessor Structure

Your Starter project already has ImageProcessor.swift with TODO stubs for three filter functions. You’ll implement each filter function to process RGBA pixel data.

Open taskmanager-lib/Sources/TaskManagerKit/ImageProcessor.swift. You’ll see three filter functions with TODO stubs: applyGrayscaleFilter, applyBlurFilter, and adjustBrightness. Each filter receives raw pixel data as a Data object, along with width and height dimensions.

The filters follow a common pattern:

  1. Validate input dimensions.
  2. Copy Data to mutable byte array.
  3. Process pixels (the algorithm).
  4. Return processed Data.

Let’s implement each filter.

Implementing the Grayscale Filter

The grayscale filter converts color images to black and white by calculating a weighted average of the RGB channels.

Replace the applyGrayscaleFilter function with this implementation:

public static func applyGrayscaleFilter(
  imageData: Data,
  width: Int32,
  height: Int32
) -> Data? {
  // 1
  guard width > 0, height > 0 else { return nil }

  // 2
  var pixels = [UInt8](repeating: 0, count: Int(width * height * 4))
  imageData.copyBytes(to: &pixels, count: pixels.count)

  // 3
  for i in stride(from: 0, to: pixels.count, by: 4) {
    let r = Float(pixels[i])
    let g = Float(pixels[i + 1])
    let b = Float(pixels[i + 2])

    // 4
    let gray = UInt8(0.299 * r + 0.587 * g + 0.114 * b)

    // 5
    pixels[i] = gray
    pixels[i + 1] = gray
    pixels[i + 2] = gray
    // Alpha (i+3) remains unchanged
  }

  // 6
  return Data(pixels)
}

What each section does:

  1. Validation: Returns nil if dimensions are invalid.
  2. Create mutable array: Copies Data into a modifiable byte array.
  3. Loop through pixels: Uses stride to jump 4 bytes at a time (one pixel).
  4. Calculate gray value: Uses the luminosity formula (ITU-R BT.601 standard).
  5. Set RGB to gray: Replaces color channels with the same gray value.
  6. Return result: Converts array back to Data.

Why these specific weights?

The formula 0.299*R + 0.587*G + 0.114*B accounts for how the human eye perceives different colors. We’re more sensitive to green, moderately sensitive to red, and least sensitive to blue. This creates more natural-looking grayscale images than simply averaging the three channels.

Example conversion:

A bright yellow pixel [255, 255, 0, 255] becomes:

gray = 0.299 * 255 + 0.587 * 255 + 0.114 * 0
gray = 76.245 + 149.685 + 0
gray = 225.93 → 226 (rounded)
Result: [226, 226, 226, 255]

Implementing the Blur Filter

The blur filter creates a soft-focus effect by averaging each pixel with its surrounding neighbors. This is called a box blur.

Replace the applyBlurFilter function with this implementation:

public static func applyBlurFilter(
  imageData: Data,
  width: Int32,
  height: Int32,
  radius: Int32 = 5
) -> Data? {
  // 1
  guard width > 0, height > 0, radius > 0 else { return nil }

  // 2
  let safeRadius = min(radius, 10)

  var pixels = [UInt8](repeating: 0, count: Int(width * height * 4))
  imageData.copyBytes(to: &pixels, count: pixels.count)

  var output = [UInt8](repeating: 0, count: Int(width * height * 4))

  // 3
  for i in stride(from: 0, to: pixels.count, by: 4) {
    output[i + 3] = pixels[i + 3]
  }

  // 4
  let step = 1

  for y in stride(from: 0, to: Int(height), by: step) {
    for x in stride(from: 0, to: Int(width), by: step) {
      // 5
      var r: Float = 0, g: Float = 0, b: Float = 0
      var count: Float = 0

      // 6
      for ky in -Int(safeRadius)...Int(safeRadius) {
        for kx in -Int(safeRadius)...Int(safeRadius) {
          let px = x + kx
          let py = y + ky

          // 7
          guard px >= 0, px < Int(width), py >= 0, py < Int(height) else { continue }

          let index = (py * Int(width) + px) * 4

          r += Float(pixels[index])
          g += Float(pixels[index + 1])
          b += Float(pixels[index + 2])
          count += 1
        }
      }

      // 8
      let index = (y * Int(width) + x) * 4
      output[index] = UInt8(r / count)
      output[index + 1] = UInt8(g / count)
      output[index + 2] = UInt8(b / count)
    }
  }

  return Data(output)
}

What each section does:

  1. Validation: Ensures dimensions and radius are positive.
  2. Limit radius: Caps radius at 10 to prevent excessive computation.
  3. Copy alpha channel: Alpha values don’t need blurring.
  4. Step size: Process every pixel (step=1).
  5. Initialize accumulators: Variables to sum RGB values.
  6. Kernel loop: Iterates over surrounding pixels within radius.
  7. Bounds checking: Skips pixels outside image boundaries.
  8. Calculate average: Divides summed values by count.

How box blur works:

For each pixel at position (x, y), the algorithm:

  1. Looks at all pixels within the specified radius.
  2. Sums their RGB values separately.
  3. Divides by the count of pixels examined.
  4. Sets the result as the new pixel value.

Examines 5x5 grid of neighbors Target pixel at (x, y) 25 pixels total Averages RGB values Sets result as new pixel Box Blur Kernel (radius = 2)
Box blur kernel visualization

For a radius of 2, each pixel is averaged with a 5x5 grid of neighbors (25 pixels total). Larger radii create stronger blur effects but require more computation.

Performance note:

Box blur has O(width × height × radius²) complexity. A 1000x1000 image with radius 5 requires about 25 million operations. That’s why you limit the radius and scale down large images.

Implementing Brightness Adjustment

The brightness filter adds or subtracts a value from each RGB channel, making the image lighter or darker.

Replace the adjustBrightness function with this implementation:

public static func adjustBrightness(
  imageData: Data,
  width: Int32,
  height: Int32,
  amount: Float
) -> Data? {
  // 1
  guard width > 0, height > 0 else { return nil }

  var pixels = [UInt8](repeating: 0, count: Int(width * height * 4))
  imageData.copyBytes(to: &pixels, count: pixels.count)

  // 2
  for i in stride(from: 0, to: pixels.count, by: 4) {
    let r = Float(pixels[i]) + amount
    let g = Float(pixels[i + 1]) + amount
    let b = Float(pixels[i + 2]) + amount

    // 3
    pixels[i] = UInt8(max(0, min(255, r)))
    pixels[i + 1] = UInt8(max(0, min(255, g)))
    pixels[i + 2] = UInt8(max(0, min(255, b)))
    // Alpha (i+3) remains unchanged
  }

  return Data(pixels)
}

What each section does:

  1. Validation: Checks for positive dimensions.
  2. Add amount: Applies brightness adjustment to each channel.
  3. Clamp values: Ensures results stay within valid 0-255 range.

Why clamping matters:

Without clamping, values can overflow or underflow:

// Without clamping
let r = 200.0 + 100.0  // = 300.0 (invalid!)
let pixel = UInt8(r)    // Wraps to 44 (incorrect)

// With clamping
let r = max(0, min(255, 200.0 + 100.0))  // = 255.0 (correct)
let pixel = UInt8(r)    // = 255 (white, not wrapped)

Positive vs negative amount:

  • amount = 50: Makes image brighter (adds 50 to each channel).
  • amount = -50: Makes image darker (subtracts 50 from each channel).

Implementing the File Processing Wrapper

Now implement the function that ties everything together by reading files, applying filters, and writing results.

In the same taskmanager-lib/Sources/TaskManagerKit/ImageProcessor.swift file, add the processImageFile function at the end of the struct (after the adjustBrightness function):

public static func processImageFile(
  inputPath: String,
  outputPath: String,
  filterType: String,
  amount: Float = 0
) -> Bool {
  // 1
  let inputURL = URL(fileURLWithPath: inputPath)
  guard let fileData = try? Data(contentsOf: inputURL), fileData.count >= 8 else {
    return false
  }

  // 2
  let headerBytes = Array(fileData.prefix(8))

  let width = Int32((UInt32(headerBytes[0]) << 24) |
                    (UInt32(headerBytes[1]) << 16) |
                    (UInt32(headerBytes[2]) << 8) |
                    UInt32(headerBytes[3]))
  let height = Int32((UInt32(headerBytes[4]) << 24) |
                     (UInt32(headerBytes[5]) << 16) |
                     (UInt32(headerBytes[6]) << 8) |
                     UInt32(headerBytes[7]))

  guard width > 0, height > 0 else {
    return false
  }

  // 3
  let pixelData = fileData.subdata(in: 8..<fileData.count)

  // 4
  let processedData: Data?
  switch filterType.lowercased() {
  case "grayscale":
    processedData = applyGrayscaleFilter(imageData: pixelData, width: width, height: height)
  case "blur":
    processedData = applyBlurFilter(imageData: pixelData, width: width, height: height)
  case "brighter":
    processedData = adjustBrightness(imageData: pixelData, width: width, height: height, amount: 50)
  case "darker":
    processedData = adjustBrightness(imageData: pixelData, width: width, height: height, amount: -50)
  default:
    processedData = pixelData
  }

  guard let finalPixels = processedData else {
    return false
  }

  // 5
  let outputURL = URL(fileURLWithPath: outputPath)
  do {
    var outputData = Data()

    // 6
    outputData.append(UInt8((width >> 24) & 0xFF))
    outputData.append(UInt8((width >> 16) & 0xFF))
    outputData.append(UInt8((width >> 8) & 0xFF))
    outputData.append(UInt8(width & 0xFF))
    outputData.append(UInt8((height >> 24) & 0xFF))
    outputData.append(UInt8((height >> 16) & 0xFF))
    outputData.append(UInt8((height >> 8) & 0xFF))
    outputData.append(UInt8(height & 0xFF))

    // 7
    outputData.append(finalPixels)

    try outputData.write(to: outputURL)
    return true
  } catch {
    return false
  }
}

What each section does:

  1. Read input file: Loads file data and validates minimum size.
  2. Parse header: Extracts width and height using bit shifting.
  3. Extract pixels: Gets pixel data after the 8-byte header.
  4. Apply filter: Calls appropriate filter based on filterType string.
  5. Prepare output: Creates URL for output file.
  6. Write header: Encodes width and height as 8 bytes (big-endian).
  7. Write pixels: Appends processed pixel data and saves to disk.

This function is called from Kotlin through the JNI bindings generated by swift-java. It handles the complete process of reading a custom format file, applying the requested filter, and writing the result back.

Building and Testing

Build your Swift package to verify everything compiles:

cd taskmanager-lib
swift build

You should see no errors. The swift-java plugin will generate Java bindings for ImageProcessor.processImageFile(), which Kotlin can call through ImageProcessingRepository. Clean the project then build and run the whole app. No changes will be visible yet.

Key Takeaways

In this segment, you’ve:

  • Implemented three image processing algorithms in Swift.
  • Learned the luminosity formula for grayscale conversion.
  • Understood box blur and kernel-based filtering.
  • Implemented brightness adjustment with value clamping.
  • Added a file processing wrapper for the complete pipeline.
  • Worked with raw byte arrays and type conversions.

Your Swift filters are now ready to process images. In the next segment, you’ll connect the UI to these filters and test the complete flow from camera capture to filtered display.

See forum comments
Download course materials from Github
Previous: Binary Data & Image Format Next: UI Integration & Testing