본문 바로가기

학습 노트/Swift UI Trick

이미지의 평균 색상을 추출하기

iOS나 Mac OS의 현행 디자인 코드는 Flat으로 음영과 그림자 등의 그래픽 적인 요소들을 최대한 배제하고,
색과 선, 레이어의 적층으로 깊이와 구분감을 부여하는 것이 특징이다.
개인적으로 이러한 상황에서 현재 시각적으로 가장 미려하다고 생각되는 부분은 바로 Material이다.

 

SwiftUI에서 Blur를 사용하는 4가지 방법

Blur Apple Developer Documentation developer.apple.com struct ContentView: View { var body: some View { ZStack() { Image("bg.sample") .resizable() .ignoresSafeArea() .scaledToFill() .blur(radius: 20) Text("Blur") .font(.largeTitle) .foregroundColor(.white)

chillog.page

일반적인 Blur와는 조금 다르게 굉장히 Matte 한 느낌을 주는 것이 특징이고,
텍스트 등 강조가 필요한 부분은 반대로 투명하게 표현해 경우에 따라 시원한 분위기를 만들 수 있다.
위와 같이 Material도 훌륭하지만 이번에는 화면에 표시된 이미지의 평균적인 색상이 어떤 건지 판단하는 방법에 대해 살펴본다.

이를테면 위와 같이 앨범 재킷이 어떤 색을 주로 가지고 있는지에 따라 더 단순한 형태로 배경을 바꾸는 것이 가능해진다.

CoreImage 사용하기

extension UIImage {
    var averageColor: UIColor?
    {
        guard let inputImage = CIImage(image: self) else { return nil }

        let extentVector = CIVector(x: inputImage.extent.origin.x, y: inputImage.extent.origin.y, z: inputImage.extent.size.width, w: inputImage.extent.size.height)

        guard let filter = CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: inputImage, kCIInputExtentKey: extentVector]) else {
            return nil
        }

        guard let outputImage = filter.outputImage else { return nil }

        var bitmap = [UInt8](repeating: 0, count: 4)
        let context = CIContext(options: [.workingColorSpace: kCFNull!])

        context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil)

        return UIColor(red: CGFloat(bitmap[0]) / 255, green: CGFloat(bitmap[1]) / 255, blue: CGFloat(bitmap[2]) / 255, alpha: CGFloat(bitmap[3]) / 255)
    }
}
더보기

Source

//
//  ContentView.swift
//  BGTest
//
//  Created by Martin.Q on 2023/04/06.
//

import SwiftUI

struct ContentView: View {
    @State private var choosen = 1
    @State private var bgc: UIColor = .clear
    var body: some View {
        TabView(selection: $choosen) {
            Group {
                Image("image1")
                    .resizable()
                    .tag(1)
                Image("image2")
                    .resizable()
                    .tag(2)
            }
            .frame(width: 350, height: 350)
        }
        .tabViewStyle(.page)
        .background(
            Color(bgc)
                .ignoresSafeArea()
                .overlay(Material.thinMaterial)
        )
        .onChange(of: choosen) { newValue in
            withAnimation {
                if choosen == 1 {
                    bgc = UIImage(named: "image1")?.averageColor ?? .clear
                } else if choosen == 2 {
                    bgc = UIImage(named: "image2")?.averageColor ?? .clear
                }
            }
        }
        .onAppear {
            if choosen == 1 {
                bgc = UIImage(named: "image1")?.averageColor ?? .clear
            } else if choosen == 2 {
                bgc = UIImage(named: "image2")?.averageColor ?? .clear
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

extension UIImage {
    var averageColor: UIColor?
    {
        guard let inputImage = CIImage(image: self) else { return nil }

        let extentVector = CIVector(x: inputImage.extent.origin.x, y: inputImage.extent.origin.y, z: inputImage.extent.size.width, w: inputImage.extent.size.height)

        guard let filter = CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: inputImage, kCIInputExtentKey: extentVector]) else {
            return nil
        }

        guard let outputImage = filter.outputImage else { return nil }

        var bitmap = [UInt8](repeating: 0, count: 4)
        let context = CIContext(options: [.workingColorSpace: kCFNull!])

        context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil)

        return UIColor(red: CGFloat(bitmap[0]) / 255, green: CGFloat(bitmap[1]) / 255, blue: CGFloat(bitmap[2]) / 255, alpha: CGFloat(bitmap[3]) / 255)
    }
}

 

Pixel 접근 방식 사용하기

extension UIImage {
	enum AverageColorAlgorithm {
		case simple
		case squareRoot
	}

	func findAverageColor(algorithm: AverageColorAlgorithm = .simple) -> UIColor? {
		guard let cgImage = cgImage else { return nil }

		let size = CGSize(width: 40, height: 40)
		let width = Int(size.width)
		let height = Int(size.height)
		let totalPixels = width * height
		let colorSpace = CGColorSpaceCreateDeviceRGB()
		let bitmapInfo: UInt32 = CGBitmapInfo.byteOrder32Little.rawValue | CGImageAlphaInfo.premultipliedFirst.rawValue

		guard let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * 4, space: colorSpace, bitmapInfo: bitmapInfo) else { return nil }

		context.draw(cgImage, in: CGRect(origin: .zero, size: size))

		guard let pixelBuffer = context.data else { return nil }

		let pointer = pixelBuffer.bindMemory(to: UInt32.self, capacity: width * height)
		var totalRed = 0
		var totalBlue = 0
		var totalGreen = 0

		for x in 0 ..< width {
			for y in 0 ..< height {
				let pixel = pointer[(y * width) + x]
				let r = red(for: pixel)
				let g = green(for: pixel)
				let b = blue(for: pixel)

				switch algorithm {
				case .simple:
					totalRed += Int(r)
					totalBlue += Int(b)
					totalGreen += Int(g)
				case .squareRoot:
					totalRed += Int(pow(CGFloat(r), CGFloat(2)))
					totalGreen += Int(pow(CGFloat(g), CGFloat(2)))
					totalBlue += Int(pow(CGFloat(b), CGFloat(2)))
				}
			}
		}

		let averageRed: CGFloat
		let averageGreen: CGFloat
		let averageBlue: CGFloat

		switch algorithm {
		case .simple:
			averageRed = CGFloat(totalRed) / CGFloat(totalPixels)
			averageGreen = CGFloat(totalGreen) / CGFloat(totalPixels)
			averageBlue = CGFloat(totalBlue) / CGFloat(totalPixels)
		case .squareRoot:
			averageRed = sqrt(CGFloat(totalRed) / CGFloat(totalPixels))
			averageGreen = sqrt(CGFloat(totalGreen) / CGFloat(totalPixels))
			averageBlue = sqrt(CGFloat(totalBlue) / CGFloat(totalPixels))
		}

		return UIColor(red: averageRed / 255.0, green: averageGreen / 255.0, blue: averageBlue / 255.0, alpha: 1.0)
	}

	private func red(for pixelData: UInt32) -> UInt8 {
		return UInt8((pixelData >> 16) & 255)
	}

	private func green(for pixelData: UInt32) -> UInt8 {
		return UInt8((pixelData >> 8) & 255)
	}

	private func blue(for pixelData: UInt32) -> UInt8 {
		return UInt8((pixelData >> 0) & 255)
	}
}
더보기

Source

//
//  ContentView.swift
//  BGTest
//
//  Created by Martin.Q on 2023/04/06.
//

import SwiftUI

struct ContentView: View {
	@State private var choosen = 1
	@State private var bgc: UIColor = .clear
    var body: some View {
		TabView(selection: $choosen) {
			Group {
				Image("image1")
					.resizable()
					.tag(1)
				Image("image2")
					.resizable()
					.tag(2)
			}
			.frame(width: 350, height: 350)
		}
		.tabViewStyle(.page)
		.background(
			Color(bgc)
				.ignoresSafeArea()
				.overlay(Material.thinMaterial)
		)
		.onAppear {
			if choosen == 1 {
				withAnimation {
					if choosen == 1 {
						bgc = UIImage(named: "image1")?.findAverageColor(algorithm: .squareRoot) ?? .clear
					} else if choosen == 2 {
						bgc = UIImage(named: "image2")?.findAverageColor(algorithm: .squareRoot) ?? .clear
					}
				}
			}
		}
		.onChange(of: choosen) { newValue in
			withAnimation {
				if choosen == 1 {
					bgc = UIImage(named: "image1")?.findAverageColor(algorithm: .squareRoot) ?? .clear
				} else if choosen == 2 {
					bgc = UIImage(named: "image2")?.findAverageColor(algorithm: .squareRoot) ?? .clear
				}
			}
		}
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

extension UIImage {
	enum AverageColorAlgorithm {
		case simple
		case squareRoot
	}

	func findAverageColor(algorithm: AverageColorAlgorithm = .simple) -> UIColor? {
		guard let cgImage = cgImage else { return nil }

		let size = CGSize(width: 40, height: 40)

		let width = Int(size.width)
		let height = Int(size.height)
		let totalPixels = width * height

		let colorSpace = CGColorSpaceCreateDeviceRGB()

		let bitmapInfo: UInt32 = CGBitmapInfo.byteOrder32Little.rawValue | CGImageAlphaInfo.premultipliedFirst.rawValue

		guard let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * 4, space: colorSpace, bitmapInfo: bitmapInfo) else { return nil }

		context.draw(cgImage, in: CGRect(origin: .zero, size: size))

		guard let pixelBuffer = context.data else { return nil }

		let pointer = pixelBuffer.bindMemory(to: UInt32.self, capacity: width * height)

		var totalRed = 0
		var totalBlue = 0
		var totalGreen = 0

		for x in 0 ..< width {
			for y in 0 ..< height {
				let pixel = pointer[(y * width) + x]

				let r = red(for: pixel)
				let g = green(for: pixel)
				let b = blue(for: pixel)

				switch algorithm {
				case .simple:
					totalRed += Int(r)
					totalBlue += Int(b)
					totalGreen += Int(g)
				case .squareRoot:
					totalRed += Int(pow(CGFloat(r), CGFloat(2)))
					totalGreen += Int(pow(CGFloat(g), CGFloat(2)))
					totalBlue += Int(pow(CGFloat(b), CGFloat(2)))
				}
			}
		}

		let averageRed: CGFloat
		let averageGreen: CGFloat
		let averageBlue: CGFloat

		switch algorithm {
		case .simple:
			averageRed = CGFloat(totalRed) / CGFloat(totalPixels)
			averageGreen = CGFloat(totalGreen) / CGFloat(totalPixels)
			averageBlue = CGFloat(totalBlue) / CGFloat(totalPixels)
		case .squareRoot:
			averageRed = sqrt(CGFloat(totalRed) / CGFloat(totalPixels))
			averageGreen = sqrt(CGFloat(totalGreen) / CGFloat(totalPixels))
			averageBlue = sqrt(CGFloat(totalBlue) / CGFloat(totalPixels))
		}

		return UIColor(red: averageRed / 255.0, green: averageGreen / 255.0, blue: averageBlue / 255.0, alpha: 1.0)
	}

	private func red(for pixelData: UInt32) -> UInt8 {
		return UInt8((pixelData >> 16) & 255)
	}

	private func green(for pixelData: UInt32) -> UInt8 {
		return UInt8((pixelData >> 8) & 255)
	}

	private func blue(for pixelData: UInt32) -> UInt8 {
		return UInt8((pixelData >> 0) & 255)
	}
}

코드가 조금 길어지긴 했지만 pixel 접근 방식도 못지않은 결과를 보여준다.
두 방식은 비슷한 결과를 가지지만 접근 방식이 다른 만큼 내부적인 차이가 존재한다.

왼쪽이 CoreImage를 사용한 방식, 오른쪽이 Pixel 접근 방식이다.
동작시 메모리 사용량이 4MB 정도 차이 나는 것을 확인할 수 있다.
사진에서 약간 높아 보이는 CPU 사용량은 이벤트가 많아지면 동일한 수준으로 수렴한다.

이외에도 다른 방법이 존재하는데, 이미지를 구성하는 몇 가지 색을 추려내는 방식인데 응용하면 동일한 효과를 볼 수 있다.

 

GitHub - indragiek/DominantColor: Finding dominant colors of an image using k-means clustering

Finding dominant colors of an image using k-means clustering - GitHub - indragiek/DominantColor: Finding dominant colors of an image using k-means clustering

github.com

코드 그 자체로도 유용해 보이니 나중에 한 번 더 다뤄 보는 것도 좋을 것 같다.


참고

 

More Efficient/Faster Average Color of Image

Calculating the average color for an image has lots of uses, this post goes over ways you can do this

christianselig.com

 

 

SwiftUI: Read the Average Color of an Image.

Get the average color of an image and set it as our background similar to Instagram Stories.

medium.com