-
-
Notifications
You must be signed in to change notification settings - Fork 22
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add method to extract prominent colors from Artwork
- Loading branch information
1 parent
d1d60e3
commit 6cd2b22
Showing
6 changed files
with
351 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
164
Sources/MusadoraKit/Extension/CommonImageProcessing.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |