From 6cd2b22a38bd26af72fe0a12b7a2fdce85e64323 Mon Sep 17 00:00:00 2001 From: Rudrank Riyam Date: Mon, 14 Oct 2024 12:49:36 +0530 Subject: [PATCH] Add method to extract prominent colors from Artwork --- Sources/MusadoraKit/Extension/Artwork.swift | 52 ++++++ .../Extension/CommonImageProcessing.swift | 164 ++++++++++++++++++ Sources/MusadoraKit/Extension/NSColor.swift | 36 ++++ Sources/MusadoraKit/Extension/NSImage.swift | 29 ++++ Sources/MusadoraKit/Extension/UIColor.swift | 34 ++++ Sources/MusadoraKit/Extension/UIImage.swift | 36 ++++ 6 files changed, 351 insertions(+) create mode 100644 Sources/MusadoraKit/Extension/Artwork.swift create mode 100644 Sources/MusadoraKit/Extension/CommonImageProcessing.swift create mode 100644 Sources/MusadoraKit/Extension/NSColor.swift create mode 100644 Sources/MusadoraKit/Extension/NSImage.swift create mode 100644 Sources/MusadoraKit/Extension/UIColor.swift create mode 100644 Sources/MusadoraKit/Extension/UIImage.swift diff --git a/Sources/MusadoraKit/Extension/Artwork.swift b/Sources/MusadoraKit/Extension/Artwork.swift new file mode 100644 index 00000000..24b28c23 --- /dev/null +++ b/Sources/MusadoraKit/Extension/Artwork.swift @@ -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) } + } +} diff --git a/Sources/MusadoraKit/Extension/CommonImageProcessing.swift b/Sources/MusadoraKit/Extension/CommonImageProcessing.swift new file mode 100644 index 00000000..5cc31aa1 --- /dev/null +++ b/Sources/MusadoraKit/Extension/CommonImageProcessing.swift @@ -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.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.. 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.. 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) + } +} diff --git a/Sources/MusadoraKit/Extension/NSColor.swift b/Sources/MusadoraKit/Extension/NSColor.swift new file mode 100644 index 00000000..75ff580a --- /dev/null +++ b/Sources/MusadoraKit/Extension/NSColor.swift @@ -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 diff --git a/Sources/MusadoraKit/Extension/NSImage.swift b/Sources/MusadoraKit/Extension/NSImage.swift new file mode 100644 index 00000000..549bced9 --- /dev/null +++ b/Sources/MusadoraKit/Extension/NSImage.swift @@ -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 diff --git a/Sources/MusadoraKit/Extension/UIColor.swift b/Sources/MusadoraKit/Extension/UIColor.swift new file mode 100644 index 00000000..7d910db7 --- /dev/null +++ b/Sources/MusadoraKit/Extension/UIColor.swift @@ -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 diff --git a/Sources/MusadoraKit/Extension/UIImage.swift b/Sources/MusadoraKit/Extension/UIImage.swift new file mode 100644 index 00000000..f00ec7df --- /dev/null +++ b/Sources/MusadoraKit/Extension/UIImage.swift @@ -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