├── .gitignore ├── Package.swift ├── README.md ├── Sources └── HistogramView │ ├── CGImage+Histogram.swift │ ├── HistogramChannel.swift │ ├── HistogramView.swift │ └── Path+Interpolation.swift └── Tests └── HistogramViewTests └── HistogramViewTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "HistogramView", 8 | platforms: [ 9 | .iOS(.v13), 10 | .macOS(.v10_15) 11 | ], 12 | products: [ 13 | // Products define the executables and libraries a package produces, and make them visible to other packages. 14 | .library( 15 | name: "HistogramView", 16 | targets: ["HistogramView"]), 17 | ], 18 | dependencies: [ 19 | // Dependencies declare other packages that this package depends on. 20 | // .package(url: /* package url */, from: "1.0.0"), 21 | ], 22 | targets: [ 23 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 24 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 25 | .target( 26 | name: "HistogramView", 27 | dependencies: []), 28 | .testTarget( 29 | name: "HistogramViewTests", 30 | dependencies: ["HistogramView"]), 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![HistogramView](https://user-images.githubusercontent.com/156458/138126267-b1799ab2-87fe-4e7a-86bd-e36cdd9aa6bd.png) 2 | 3 | # HistogramView 4 | 5 | A SwiftUI view for displaying image histograms. 6 | 7 | ## How do I use it? 8 | 9 | It's as simple as: 10 | ``` swift 11 | HistogramView(image: myImage) 12 | ``` 13 | 14 | *Note:* Both `UIImage` & `NSImage` are supported (by the `HistogramImage` typealias, depending on the platform). 15 | 16 | ## What options do I have for configuration? 17 | 18 | The initializer supports channel opacity, blendMode and scale for the final graph. 19 | 20 | ``` swift 21 | /// The opacity of each channel layer. Default is `1` 22 | public let channelOpacity: CGFloat 23 | 24 | /// The blend mode for the channel layers. Default is `.screen` 25 | public let blendMode: BlendMode 26 | 27 | /// The scale of each layer. Default is `1` 28 | public let scale: CGFloat 29 | ``` 30 | 31 | ## How fast is this thing? 32 | 33 | Under the hood the histogram calculation is performed by `Accelerate`'s `vImageHistogramCalculation_ARGB8888` for RGB channels, so it's pretty fast actually. 34 | Fast enough to be perfomed synchronously (although didn't test it on gigantic images). 35 | 36 | ## How is the graph curve produced? 37 | 38 | Each channel is a `SwiftUI` `Path` that uses [Hermite interpolation](https://en.wikipedia.org/wiki/Hermite_interpolation) for generating a continous curve. 39 | The actual implementation for the interpolator is taken from [@FlexMonkey's implementation](https://github.com/FlexMonkey/Filterpedia/blob/7a0d4a7070894eb77b9d1831f689f9d8765c12ca/Filterpedia/components/HistogramDisplay.swift#L228) (part of the [Filterpedia](https://github.com/FlexMonkey/Filterpedia) project) and adapted to be used on `Path` instead of `UIBezierPath`. 40 | 41 | 42 | ## Authors 43 | Vasilis Akoinoglou, alladinian@gmail.com 44 | -------------------------------------------------------------------------------- /Sources/HistogramView/CGImage+Histogram.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CGImage+Histogram.swift 3 | // 4 | // 5 | // Created by Vasilis Akoinoglou on 20/10/21. 6 | // 7 | 8 | import Foundation 9 | import Accelerate 10 | 11 | extension CGImage { 12 | 13 | /// The function calculates the histogram for each channel completely separately from the others. 14 | /// - Returns: A tuple contain the three histograms for the corresponding channels. Each of the three histograms will be an array with 256 elements. 15 | func histogram() -> (red: [UInt], green: [UInt], blue: [UInt])? { 16 | let format = vImage_CGImageFormat( 17 | bitsPerComponent: 8, 18 | bitsPerPixel: 32, 19 | colorSpace: CGColorSpaceCreateDeviceRGB(), 20 | bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), 21 | renderingIntent: .defaultIntent)! 22 | 23 | guard var sourceBuffer = try? vImage_Buffer(cgImage: self, format: format) else { 24 | return nil 25 | } 26 | 27 | defer { 28 | sourceBuffer.free() 29 | } 30 | 31 | var histogramBinZero = [vImagePixelCount](repeating: 0, count: 256) 32 | var histogramBinOne = [vImagePixelCount](repeating: 0, count: 256) 33 | var histogramBinTwo = [vImagePixelCount](repeating: 0, count: 256) 34 | var histogramBinThree = [vImagePixelCount](repeating: 0, count: 256) 35 | 36 | histogramBinZero.withUnsafeMutableBufferPointer { zeroPtr in 37 | histogramBinOne.withUnsafeMutableBufferPointer { onePtr in 38 | histogramBinTwo.withUnsafeMutableBufferPointer { twoPtr in 39 | histogramBinThree.withUnsafeMutableBufferPointer { threePtr in 40 | 41 | var histogramBins = [zeroPtr.baseAddress, onePtr.baseAddress, 42 | twoPtr.baseAddress, threePtr.baseAddress] 43 | 44 | histogramBins.withUnsafeMutableBufferPointer { histogramBinsPtr in 45 | let error = vImageHistogramCalculation_ARGB8888(&sourceBuffer, 46 | histogramBinsPtr.baseAddress!, 47 | vImage_Flags(kvImageNoFlags)) 48 | 49 | guard error == kvImageNoError else { 50 | fatalError("Error calculating histogram: \(error)") 51 | } 52 | } 53 | } 54 | } 55 | } 56 | } 57 | 58 | return (histogramBinZero, histogramBinOne, histogramBinTwo) 59 | } 60 | 61 | } 62 | 63 | #if os(macOS) 64 | import AppKit 65 | public extension NSImage { 66 | var cgImage: CGImage? { 67 | guard let imageData = tiffRepresentation else { return nil } 68 | guard let sourceData = CGImageSourceCreateWithData(imageData as CFData, nil) else { return nil } 69 | return CGImageSourceCreateImageAtIndex(sourceData, 0, nil) 70 | } 71 | } 72 | #endif 73 | -------------------------------------------------------------------------------- /Sources/HistogramView/HistogramChannel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HistogramChannel.swift 3 | // 4 | // 5 | // Created by Vasilis Akoinoglou on 20/10/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct HistogramChannel: Shape { 11 | 12 | let data: [UInt] 13 | let scale: CGFloat 14 | 15 | private var maximum: UInt { data.max() ?? 0 } 16 | 17 | func path(in rect: CGRect) -> Path { 18 | Path { path in 19 | path.move(to: CGPoint(x: 0, y: rect.height)) 20 | 21 | let interpolationPoints: [CGPoint] = data.enumerated().map { (index, element) in 22 | let y = rect.height - (CGFloat(element) / CGFloat(maximum) * rect.height) * scale 23 | let x = CGFloat(index) / CGFloat(data.count) * rect.width 24 | return CGPoint(x: x, y: y) 25 | } 26 | 27 | guard let curves = Path.interpolatePointsWithHermite(interpolationPoints: interpolationPoints) else { 28 | return 29 | } 30 | 31 | path.addPath(curves) 32 | path.addLine(to: CGPoint(x: rect.width, y: rect.height)) 33 | path.addLine(to: CGPoint(x: 0, y: rect.height)) 34 | } 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /Sources/HistogramView/HistogramView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import CoreGraphics 3 | import Accelerate 4 | 5 | #if canImport(UIKit) 6 | import UIKit 7 | public typealias HistogramImage = UIImage 8 | #endif 9 | 10 | #if canImport(AppKit) 11 | import AppKit 12 | public typealias HistogramImage = NSImage 13 | #endif 14 | 15 | 16 | /// A SwiftUI Image Histogram View (for RGB channels) 17 | public struct HistogramView: View { 18 | 19 | /// The image from which the histogram will be calculated 20 | public let image: CGImage 21 | 22 | /// The opacity of each channel layer. Default is `1` 23 | public let channelOpacity: CGFloat 24 | 25 | /// The blend mode for the channel layers. Default is `.screen` 26 | public let blendMode: BlendMode 27 | 28 | /// The scale of each layer. Default is `1` 29 | public let scale: CGFloat 30 | 31 | public init(image: HistogramImage, channelOpacity: CGFloat = 1, blendMode: BlendMode = .screen, scale: CGFloat = 1) { 32 | self.image = image.cgImage! 33 | self.channelOpacity = channelOpacity 34 | self.blendMode = blendMode 35 | self.scale = scale 36 | } 37 | 38 | public var body: some View { 39 | if let data = image.histogram() { 40 | ZStack { 41 | Group { 42 | HistogramChannel(data: data.red, scale: scale).foregroundColor(.red) 43 | HistogramChannel(data: data.green, scale: scale).foregroundColor(.green) 44 | HistogramChannel(data: data.blue, scale: scale).foregroundColor(.blue) 45 | } 46 | .opacity(channelOpacity) 47 | .blendMode(blendMode) 48 | } 49 | .id(image) 50 | .drawingGroup() 51 | } 52 | } 53 | } 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /Sources/HistogramView/Path+Interpolation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Vasilis Akoinoglou on 20/10/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension Path { 11 | static func interpolatePointsWithHermite(interpolationPoints: [CGPoint], alpha: CGFloat = 1.0 / 3.0) -> Path? { 12 | 13 | guard !interpolationPoints.isEmpty else { return nil } 14 | 15 | var path = Path() 16 | 17 | path.move(to: interpolationPoints[0]) 18 | 19 | let n = interpolationPoints.count - 1 20 | 21 | for index in 0.. 0 { 33 | mx = (nextPoint.x - previousPoint.x) / 2.0 34 | my = (nextPoint.y - previousPoint.y) / 2.0 35 | } else { 36 | mx = (nextPoint.x - currentPoint.x) / 2.0 37 | my = (nextPoint.y - currentPoint.y) / 2.0 38 | } 39 | 40 | let controlPoint1 = CGPoint(x: currentPoint.x + mx * alpha, y: currentPoint.y + my * alpha) 41 | currentPoint = interpolationPoints[nextIndex] 42 | nextIndex = (nextIndex + 1) % interpolationPoints.count 43 | prevIndex = index 44 | previousPoint = interpolationPoints[prevIndex] 45 | nextPoint = interpolationPoints[nextIndex] 46 | 47 | if index < n - 1 { 48 | mx = (nextPoint.x - previousPoint.x) / 2.0 49 | my = (nextPoint.y - previousPoint.y) / 2.0 50 | } else { 51 | mx = (currentPoint.x - previousPoint.x) / 2.0 52 | my = (currentPoint.y - previousPoint.y) / 2.0 53 | } 54 | 55 | let controlPoint2 = CGPoint(x: currentPoint.x - mx * alpha, y: currentPoint.y - my * alpha) 56 | 57 | path.addCurve(to: endPoint, control1: controlPoint1, control2: controlPoint2) 58 | } 59 | 60 | return path 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Tests/HistogramViewTests/HistogramViewTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import HistogramView 3 | 4 | final class HistogramViewTests: XCTestCase { 5 | func testExample() throws { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | } 10 | } 11 | --------------------------------------------------------------------------------