Skip to content

Commit

Permalink
Add method to extract prominent colors from Artwork
Browse files Browse the repository at this point in the history
  • Loading branch information
rudrankriyam committed Oct 14, 2024
1 parent d1d60e3 commit 6cd2b22
Show file tree
Hide file tree
Showing 6 changed files with 351 additions and 0 deletions.
52 changes: 52 additions & 0 deletions Sources/MusadoraKit/Extension/Artwork.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//
// Artwork.swift
// MusadoraKit
//
// Created by Rudrank Riyam on 10/14/24.
//

#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif

import Foundation
import SwiftUI

extension Artwork {

/// Fetches the artwork image and extracts prominent colors from it.
///
/// This function downloads the artwork image from a specified URL, processes it to extract
/// the most prominent colors, and returns them as an array of SwiftUI `Color` objects.
///
/// - Parameters:
/// - width: The desired width of the artwork image.
/// - height: The desired height of the artwork image.
/// - numberOfColors: The number of prominent colors to extract. Default is 9.
///
/// - Returns: An array of SwiftUI `Color` objects representing the prominent colors.
///
/// - Throws: An error if the image cannot be fetched or processed.
public func fetchColors(width: Int, height: Int, numberOfColors: Int = 9) async throws -> [Color] {
guard let imageURL = self.url(width: width, height: height) else {
throw NSError(domain: "Invalid artwork URL", code: 0, userInfo: nil)
}

let (data, _) = try await URLSession.shared.data(from: imageURL)

#if canImport(UIKit)
guard let image = UIImage(data: data) else {
throw NSError(domain: "Invalid image data", code: 0, userInfo: nil)
}
#elseif canImport(AppKit)
guard let image = NSImage(data: data) else {
throw NSError(domain: "Invalid image data", code: 0, userInfo: nil)
}
#endif

let colors = try image.extractColors(numberOfColors: numberOfColors)
return colors.map { Color($0) }
}
}
164 changes: 164 additions & 0 deletions Sources/MusadoraKit/Extension/CommonImageProcessing.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
//
// CommonImageProcessing.swift
// MusadoraKit
//
// Created by Rudrank Riyam on 10/14/24.
//

import CoreGraphics

/// Errors that can occur during image processing.
enum ImageProcessingError: Error {

/// The provided image is invalid or cannot be processed.
case invalidImage
/// The image resizing operation failed.
case resizeFailed
/// Failed to allocate memory for image processing.
case failedToAllocateMemory
/// Failed to create a CGContext for image processing.
case failedToCreateCGContext
}

/// Represents the color data of a single pixel.
struct PixelData {

/// The red component of the pixel color (0-255).
let red: Double
/// The green component of the pixel color (0-255).
let green: Double
/// The blue component of the pixel color (0-255).
let blue: Double
}

/// Represents a cluster of pixels in color space.
struct Cluster {

/// The center point of the cluster in color space.
var center: PixelData
/// The pixels belonging to this cluster.
var points: [PixelData]
}

/// A utility struct for common image processing operations.
struct CommonImageProcessing {

/// Extracts the most prominent colors from a CGImage.
///
/// - Parameters:
/// - cgImage: The CGImage to extract colors from.
/// - numberOfColors: The number of prominent colors to extract.
///
/// - Returns: An array of CGColor objects representing the prominent colors.
///
/// - Throws: An `ImageProcessingError` if the image processing fails.
static func extractColors(from cgImage: CGImage, numberOfColors: Int) throws -> [CGColor] {
let width = cgImage.width
let height = cgImage.height
let bytesPerPixel = 4
let bytesPerRow = bytesPerPixel * width
let bitsPerComponent = 8

guard let data = calloc(height * width, MemoryLayout<UInt32>.size) else {
throw ImageProcessingError.failedToAllocateMemory
}

defer { free(data) }

let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue

guard let context = CGContext(data: data, width: width, height: height,
bitsPerComponent: bitsPerComponent,
bytesPerRow: bytesPerRow,
space: colorSpace,
bitmapInfo: bitmapInfo) else {
throw ImageProcessingError.failedToCreateCGContext
}

context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))

let pixelBuffer = data.bindMemory(to: UInt8.self, capacity: width * height * bytesPerPixel)
var pixelData = [PixelData]()
for y in 0..<height {
for x in 0..<width {
let offset = ((width * y) + x) * bytesPerPixel
let r = pixelBuffer[offset]
let g = pixelBuffer[offset + 1]
let b = pixelBuffer[offset + 2]
pixelData.append(PixelData(red: Double(r), green: Double(g), blue: Double(b)))
}
}

let clusters = kMeansCluster(pixels: pixelData, k: numberOfColors)

return clusters.map { cluster -> CGColor in
CGColor(red: cluster.center.red / 255.0,
green: cluster.center.green / 255.0,
blue: cluster.center.blue / 255.0,
alpha: 1.0)
}
}

/// Performs k-means clustering on a set of pixels.
///
/// - Parameters:
/// - pixels: An array of PixelData to cluster.
/// - k: The number of clusters to create.
/// - maxIterations: The maximum number of iterations to perform (default is 10).
///
/// - Returns: An array of Cluster objects representing the final clusters.
private static func kMeansCluster(pixels: [PixelData], k: Int, maxIterations: Int = 10) -> [Cluster] {
var clusters = [Cluster]()
for _ in 0..<k {
if let randomPixel = pixels.randomElement() {
clusters.append(Cluster(center: randomPixel, points: []))
}
}

for _ in 0..<maxIterations {
for clusterIndex in 0..<clusters.count {
clusters[clusterIndex].points.removeAll()
}

for pixel in pixels {
var minDistance = Double.greatestFiniteMagnitude
var closestClusterIndex = 0
for (index, cluster) in clusters.enumerated() {
let distance = euclideanDistance(pixel1: pixel, pixel2: cluster.center)
if distance < minDistance {
minDistance = distance
closestClusterIndex = index
}
}
clusters[closestClusterIndex].points.append(pixel)
}

for clusterIndex in 0..<clusters.count {
let cluster = clusters[clusterIndex]
if cluster.points.isEmpty { continue }
let sum = cluster.points.reduce(PixelData(red: 0, green: 0, blue: 0)) { (result, pixel) -> PixelData in
return PixelData(red: result.red + pixel.red, green: result.green + pixel.green, blue: result.blue + pixel.blue)
}
let count = Double(cluster.points.count)
clusters[clusterIndex].center = PixelData(red: sum.red / count, green: sum.green / count, blue: sum.blue / count)
}
}

return clusters
}

/// Calculates the Euclidean distance between two pixels in color space.
///
/// - Parameters:
/// - pixel1: The first pixel.
/// - pixel2: The second pixel.
///
/// - Returns: The Euclidean distance between the two pixels.
private static func euclideanDistance(pixel1: PixelData, pixel2: PixelData) -> Double {
let dr = pixel1.red - pixel2.red
let dg = pixel1.green - pixel2.green
let db = pixel1.blue - pixel2.blue
return sqrt(dr * dr + dg * dg + db * db)
}
}
36 changes: 36 additions & 0 deletions Sources/MusadoraKit/Extension/NSColor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// NSColor.swift
// MusadoraKit
//
// Created by AI Assistant on 03/19/2024.
//

#if os(macOS)
import AppKit

extension NSColor {

/// Converts the NSColor to its hexadecimal string representation.
///
/// This property provides a convenient way to obtain the hexadecimal string
/// representation of an NSColor. It attempts to convert the color to the sRGB
/// color space before calculating the hex value. If the conversion fails,
/// it returns a default black color hex string.
///
/// - Returns: A string representing the color in hexadecimal format (#RRGGBB).
/// Returns "#000000" (black) if the color cannot be converted to sRGB.
///
/// - Note: The returned string is always uppercase and includes the "#" prefix.
var hexString: String {
guard let rgbColor = usingColorSpace(.sRGB) else {
return "#000000"
}

let r = Int(round(rgbColor.redComponent * 255))
let g = Int(round(rgbColor.greenComponent * 255))
let b = Int(round(rgbColor.blueComponent * 255))

return String(format: "#%02X%02X%02X", r, g, b)
}
}
#endif
29 changes: 29 additions & 0 deletions Sources/MusadoraKit/Extension/NSImage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#if os(macOS)
import AppKit

extension NSImage {

/// Extracts the most prominent and unique colors from the image.
///
/// - Parameter numberOfColors: The number of prominent colors to extract (default is 4).
/// - Returns: An array of NSColors representing the prominent colors.
func extractColors(numberOfColors: Int = 4) throws -> [NSColor] {
guard self.cgImage(forProposedRect: nil, context: nil, hints: nil) != nil else {
throw ImageProcessingError.invalidImage
}

let size = CGSize(width: 200, height: 200 * self.size.height / self.size.width)
let resizedImage = NSImage(size: size)
resizedImage.lockFocus()
self.draw(in: NSRect(origin: .zero, size: size), from: .zero, operation: .copy, fraction: 1.0)
resizedImage.unlockFocus()

guard let resizedCGImage = resizedImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
throw ImageProcessingError.resizeFailed
}

let colors = try CommonImageProcessing.extractColors(from: resizedCGImage, numberOfColors: numberOfColors)
return colors.map { NSColor(cgColor: $0)! }
}
}
#endif
34 changes: 34 additions & 0 deletions Sources/MusadoraKit/Extension/UIColor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// UIColor.swift
// MusadoraKit
//
// Created by Rudrank Riyam on 10/14/24.
//

#if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS)
import UIKit

extension UIColor {
/// Converts the UIColor to its hexadecimal string representation.
///
/// This property provides a convenient way to obtain the hexadecimal string
/// representation of a UIColor. It uses the color's RGB components to calculate
/// the hex value.
///
/// - Returns: A string representing the color in hexadecimal format (#RRGGBB).
/// Returns "#000000" (black) if the color components cannot be retrieved.
///
/// - Note: The returned string is always uppercase and includes the "#" prefix.
var hexString: String {
guard let components = self.cgColor.components, components.count >= 3 else {
return "#000000"
}

let r = Int(components[0] * 255.0)
let g = Int(components[1] * 255.0)
let b = Int(components[2] * 255.0)

return String(format: "#%02X%02X%02X", r, g, b)
}
}
#endif
36 changes: 36 additions & 0 deletions Sources/MusadoraKit/Extension/UIImage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// UIImage.swift
// MusadoraKit
//
// Created by Rudrank Riyam on 10/14/24.
//

#if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS)
import UIKit

extension UIImage {

/// Extracts the most prominent and unique colors from the image.
///
/// - Parameter numberOfColors: The number of prominent colors to extract (default is 4).
/// - Returns: An array of UIColors representing the prominent colors.
func extractColors(numberOfColors: Int = 4) throws -> [UIColor] {
// Ensure the image has a CGImage
guard let cgImage = self.cgImage else {
throw ImageProcessingError.invalidImage
}

let size = CGSize(width: 200, height: 200 * self.size.height / self.size.width)
UIGraphicsBeginImageContext(size)
self.draw(in: CGRect(origin: .zero, size: size))
guard let resizedImage = UIGraphicsGetImageFromCurrentImageContext() else {
UIGraphicsEndImageContext()
throw ImageProcessingError.resizeFailed
}
UIGraphicsEndImageContext()

let colors = try CommonImageProcessing.extractColors(from: resizedImage.cgImage!, numberOfColors: numberOfColors)
return colors.map { UIColor(cgColor: $0) }
}
}
#endif

0 comments on commit 6cd2b22

Please sign in to comment.