Skip to content

Commit

Permalink
Add ChromaKey filter feature.
Browse files Browse the repository at this point in the history
  • Loading branch information
shogo4405 committed Aug 5, 2024
1 parent 5b39c9e commit 6fa9231
Show file tree
Hide file tree
Showing 7 changed files with 222 additions and 46 deletions.
4 changes: 4 additions & 0 deletions HaishinKit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@
BC959F0E29705B1B0067BA97 /* SCStreamPublishViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC959F0D29705B1B0067BA97 /* SCStreamPublishViewController.swift */; };
BC959F1229717EDB0067BA97 /* PreferenceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC959F1129717EDB0067BA97 /* PreferenceViewController.swift */; };
BC9CFA9323BDE8B700917EEF /* IOStreamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC9CFA9223BDE8B700917EEF /* IOStreamView.swift */; };
BC9D20442C5E25C400E3D404 /* ChromaKeyProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC9D20432C5E25C400E3D404 /* ChromaKeyProcessor.swift */; };
BC9F9C7826F8C16600B01ED0 /* Choreographer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC9F9C7726F8C16600B01ED0 /* Choreographer.swift */; };
BCA3A5252BC4ED220083BBB1 /* RTMPTimestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCA3A5242BC4ED220083BBB1 /* RTMPTimestamp.swift */; };
BCA3A5272BC507880083BBB1 /* RTMPTimestampTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCA3A5262BC507880083BBB1 /* RTMPTimestampTests.swift */; };
Expand Down Expand Up @@ -699,6 +700,7 @@
BC959F0D29705B1B0067BA97 /* SCStreamPublishViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCStreamPublishViewController.swift; sourceTree = "<group>"; };
BC959F1129717EDB0067BA97 /* PreferenceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceViewController.swift; sourceTree = "<group>"; };
BC9CFA9223BDE8B700917EEF /* IOStreamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOStreamView.swift; sourceTree = "<group>"; };
BC9D20432C5E25C400E3D404 /* ChromaKeyProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChromaKeyProcessor.swift; sourceTree = "<group>"; };
BC9F9C7726F8C16600B01ED0 /* Choreographer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Choreographer.swift; sourceTree = "<group>"; };
BCA2E7F32C4B6C7E0012F2D4 /* SRTHaishinKit.podspec */ = {isa = PBXFileReference; lastKnownFileType = text; path = SRTHaishinKit.podspec; sourceTree = "<group>"; };
BCA3A5242BC4ED220083BBB1 /* RTMPTimestamp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTMPTimestamp.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1317,6 +1319,7 @@
isa = PBXGroup;
children = (
BC9F9C7726F8C16600B01ED0 /* Choreographer.swift */,
BC9D20432C5E25C400E3D404 /* ChromaKeyProcessor.swift */,
BCDEB4F92BE442F900EEC6ED /* Screen.swift */,
BC16019B2BE0E4750061BD3E /* ScreenObject.swift */,
BCDEB4FB2BE4436D00EEC6ED /* ScreenObjectContainer.swift */,
Expand Down Expand Up @@ -1900,6 +1903,7 @@
BCCBCE9729A90D880095B51C /* AVCNALUnit.swift in Sources */,
BC37861D2C0F7B9900D79263 /* CMFormatDescription+Extension.swift in Sources */,
29B876BD1CD70B3900FC07DA /* CRC32.swift in Sources */,
BC9D20442C5E25C400E3D404 /* ChromaKeyProcessor.swift in Sources */,
BC4914A628DDD367009E2DF6 /* VTSessionOption.swift in Sources */,
BC1CCF622BE66C220067198A /* CGImage+Extension.swift in Sources */,
BC0F1FDA2ACC4CC100C326FF /* IOCaptureVideoPreview.swift in Sources */,
Expand Down
174 changes: 174 additions & 0 deletions Sources/Screen/ChromaKeyProcessor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import Accelerate
import Foundation
import simd

/// A type with a chroma key processorble screen object.
public protocol ChromaKeyProcessorble {
/// Specifies the chroma key color.
var chromaKeyColor: CGColor? { get set }
}

final class ChromaKeyProcessor {
static let noFlags = vImage_Flags(kvImageNoFlags)
static let labColorSpace = CGColorSpace(name: CGColorSpace.genericLab)!

enum Error: Swift.Error {
case invalidState
}

private let entriesPerChannel = 32
private let sourceChannelCount = 3
private let destinationChannelCount = 1

private let srcFormat = vImage_CGImageFormat(
bitsPerComponent: 32,
bitsPerPixel: 32 * 3,
colorSpace: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGBitmapInfo(rawValue: kCGBitmapByteOrder32Host.rawValue | CGBitmapInfo.floatComponents.rawValue | CGImageAlphaInfo.none.rawValue))

private let destFormat = vImage_CGImageFormat(
bitsPerComponent: 32,
bitsPerPixel: 32 * 3,
colorSpace: labColorSpace,
bitmapInfo: CGBitmapInfo(rawValue: kCGBitmapByteOrder32Host.rawValue | CGBitmapInfo.floatComponents.rawValue | CGImageAlphaInfo.none.rawValue))

private var tables: [CGColor: vImage_MultidimensionalTable] = [:]
private var outputF: [String: vImage_Buffer] = [:]
private var output8: [String: vImage_Buffer] = [:]
private var buffers: [String: [vImage_Buffer]] = [:]
private let converter: vImageConverter
private var maxFloats: [Float] = [1.0, 1.0, 1.0, 1.0]
private var minFloats: [Float] = [0.0, 0.0, 0.0, 0.0]

init() throws {
guard let srcFormat, let destFormat else {
throw Error.invalidState
}
converter = try vImageConverter.make(sourceFormat: srcFormat, destinationFormat: destFormat)
}

deinit {
tables.forEach { vImageMultidimensionalTable_Release($0.value) }
output8.forEach { $0.value.free() }
outputF.forEach { $0.value.free() }
buffers.forEach { $0.value.forEach { $0.free() } }
}

func makeMask(_ source: inout vImage_Buffer, chromeKeyColor: CGColor) throws -> vImage_Buffer {
let key = "\(source.width):\(source.height)"
if tables[chromeKeyColor] == nil {
tables[chromeKeyColor] = try makeLookUpTable(chromeKeyColor, tolerance: 60)
}
if outputF[key] == nil {
outputF[key] = try vImage_Buffer(width: Int(source.width), height: Int(source.height), bitsPerPixel: 32)
}
if output8[key] == nil {
output8[key] = try vImage_Buffer(width: Int(source.width), height: Int(source.height), bitsPerPixel: 8)
}
guard
let table = tables[chromeKeyColor],
let dest = outputF[key] else {
throw Error.invalidState
}
var dests: [vImage_Buffer] = [dest]
let srcs = try makePlanarFBuffers(&source)
vImageMultiDimensionalInterpolatedLookupTable_PlanarF(
srcs,
&dests,
nil,
table,
kvImageFullInterpolation,
vImage_Flags(kvImageNoFlags)
)
guard var result = output8[key] else {
throw Error.invalidState
}
vImageConvert_PlanarFtoPlanar8(&dests[0], &result, 1.0, 0.0, Self.noFlags)
return result
}

private func makePlanarFBuffers(_ source: inout vImage_Buffer) throws -> [vImage_Buffer] {
let key = "\(source.width):\(source.height)"
if buffers[key] == nil {
buffers[key] = [
try vImage_Buffer(width: Int(source.width), height: Int(source.height), bitsPerPixel: 32),
try vImage_Buffer(width: Int(source.width), height: Int(source.height), bitsPerPixel: 32),
try vImage_Buffer(width: Int(source.width), height: Int(source.height), bitsPerPixel: 32),
try vImage_Buffer(width: Int(source.width), height: Int(source.height), bitsPerPixel: 32)
]
}
guard var buffers = buffers[key] else {
throw Error.invalidState
}
vImageConvert_ARGB8888toPlanarF(
&source,
&buffers[0],
&buffers[1],
&buffers[2],
&buffers[3],
&maxFloats,
&minFloats,
Self.noFlags)
return [
buffers[1],
buffers[2],
buffers[3]
]
}

private func makeLookUpTable(_ chromaKeyColor: CGColor, tolerance: Float) throws -> vImage_MultidimensionalTable? {
let ramp = vDSP.ramp(in: 0 ... 1.0, count: Int(entriesPerChannel))
let lookupTableElementCount = Int(pow(Float(entriesPerChannel), Float(sourceChannelCount))) * Int(destinationChannelCount)
var lookupTableData = [UInt16].init(repeating: 0, count: lookupTableElementCount)
let chromaKeyRGB = chromaKeyColor.components ?? [0, 0, 0]
let chromaKeyLab = try rgbToLab(
r: chromaKeyRGB[0],
g: chromaKeyRGB.count > 1 ? chromaKeyRGB[1] : chromaKeyRGB[0],
b: chromaKeyRGB.count > 2 ? chromaKeyRGB[2] : chromaKeyRGB[0]
)
var bufferIndex = 0
for red in ramp {
for green in ramp {
for blue in ramp {
let lab = try rgbToLab(r: red, g: green, b: blue)
let distance = simd_distance(chromaKeyLab, lab)
let contrast = Float(20)
let offset = Float(0.25)
let alpha = saturate(tanh(((distance / tolerance ) - 0.5 - offset) * contrast))
lookupTableData[bufferIndex] = UInt16(alpha * Float(UInt16.max))
bufferIndex += 1
}
}
}
var entryCountPerSourceChannel = [UInt8](repeating: UInt8(entriesPerChannel), count: sourceChannelCount)
let result = vImageMultidimensionalTable_Create(
&lookupTableData,
3,
1,
&entryCountPerSourceChannel,
kvImageMDTableHint_Float,
vImage_Flags(kvImageNoFlags),
nil)
vImageMultidimensionalTable_Retain(result)
return result
}

private func rgbToLab(r: CGFloat, g: CGFloat, b: CGFloat) throws -> SIMD3<Float> {
var data: [Float] = [Float(r), Float(g), Float(b)]
var srcPixelBuffer = data.withUnsafeMutableBufferPointer { pointer in
vImage_Buffer(data: pointer.baseAddress, height: 1, width: 1, rowBytes: 4 * 3)
}
var destPixelBuffer = try vImage_Buffer(width: 1, height: 1, bitsPerPixel: 32 * 3)
vImageConvert_AnyToAny(converter, &srcPixelBuffer, &destPixelBuffer, nil, vImage_Flags(kvImageNoFlags))
let result = destPixelBuffer.data.assumingMemoryBound(to: Float.self)
return .init(
result[0],
result[1],
result[2]
)
}

private func saturate<T: FloatingPoint>(_ x: T) -> T {
return min(max(0, x), 1)
}
}
3 changes: 2 additions & 1 deletion Sources/Screen/Screen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ protocol ScreenObserver: AnyObject {

/// An object that manages offscreen rendering a foundation.
public final class Screen: ScreenObjectContainerConvertible {
static let size = CGSize(width: 1280, height: 720)
public static let size = CGSize(width: 1280, height: 720)

private static let lockFrags = CVPixelBufferLockFlags(rawValue: 0)

/// The total of child counts.
Expand Down
8 changes: 6 additions & 2 deletions Sources/Screen/ScreenObject.swift
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,9 @@ public final class ImageScreenObject: ScreenObject {
}

/// An object that manages offscreen rendering a video track source.
public final class VideoTrackScreenObject: ScreenObject {
public final class VideoTrackScreenObject: ScreenObject, ChromaKeyProcessorble {
public var chromaKeyColor: CGColor?

/// Specifies the track number how the displays the visual content.
public var track: UInt8 = 0 {
didSet {
Expand Down Expand Up @@ -384,7 +386,9 @@ public final class TextScreenObject: ScreenObject {

#if !os(visionOS)
/// An object that manages offscreen rendering an asset resource.
public final class AssetScreenObject: ScreenObject {
public final class AssetScreenObject: ScreenObject, ChromaKeyProcessorble {
public var chromaKeyColor: CGColor?

public var isReading: Bool {
return reader?.status == .reading
}
Expand Down
23 changes: 16 additions & 7 deletions Sources/Screen/ScreenRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ public protocol ScreenRenderer: AnyObject {
}

final class ScreenRendererByCPU: ScreenRenderer {
static let noFlags = vImage_Flags(kvImageNoFlags)

var bounds: CGRect = .init(origin: .zero, size: Screen.size)

lazy var context = {
Expand Down Expand Up @@ -83,6 +85,9 @@ final class ScreenRendererByCPU: ScreenRenderer {
}
}
private var backgroundColorUInt8Array: [UInt8] = [0x00, 0x00, 0x00, 0x00]
private lazy var choromaKeyProcessor: ChromaKeyProcessor? = {
return try? ChromaKeyProcessor()
}()

func setTarget(_ pixelBuffer: CVPixelBuffer?) {
guard let pixelBuffer else {
Expand Down Expand Up @@ -125,13 +130,21 @@ final class ScreenRendererByCPU: ScreenRenderer {
}
do {
images[screenObject]?.free()
images[screenObject] = try vImage_Buffer(cgImage: image, format: format)
var buffer = try vImage_Buffer(cgImage: image, format: format)
images[screenObject] = buffer
if 0 < screenObject.cornerRadius {
masks[screenObject] = shapeFactory.cornerRadius(screenObject.bounds.size, cornerRadius: screenObject.cornerRadius)
if var mask = shapeFactory.cornerRadius(image.size, cornerRadius: screenObject.cornerRadius) {
vImageOverwriteChannels_ARGB8888(&mask, &buffer, &buffer, 0x8, Self.noFlags)
}
} else {
masks[screenObject] = nil
if let screenObject = screenObject as? (any ChromaKeyProcessorble),
let chromaKeyColor = screenObject.chromaKeyColor,
var mask = try choromaKeyProcessor?.makeMask(&buffer, chromeKeyColor: chromaKeyColor) {
vImageOverwriteChannels_ARGB8888(&mask, &buffer, &buffer, 0x8, Self.noFlags)
}
}
} catch {
logger.error(error)
}
}
}
Expand All @@ -141,10 +154,6 @@ final class ScreenRendererByCPU: ScreenRenderer {
return
}

if var mask = masks[screenObject] {
vImageSelectChannels_ARGB8888(&mask, &image, &image, 0x8, vImage_Flags(kvImageNoFlags))
}

let origin = screenObject.bounds.origin
let start = Int(max(0, origin.y)) * canvas.rowBytes + Int(max(0, origin.x)) * 4

Expand Down
43 changes: 15 additions & 28 deletions Sources/Screen/Shape.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import Accelerate
import Foundation

#if os(macOS)
#if canImport(AppKit)
import AppKit
#endif

class RoundedSquareShape: Shape {
#if canImport(UIKit)
import UIKit
#endif

final class RoundedSquareShape: Shape {
var rect: CGRect = .zero
var cornerRadius: CGFloat = .zero

Expand All @@ -14,40 +19,22 @@ class RoundedSquareShape: Shape {
width: Int(rect.width),
height: Int(rect.height),
bitsPerComponent: 8,
bytesPerRow: Int(rect.width) * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue).rawValue
bytesPerRow: Int(rect.width),
space: CGColorSpaceCreateDeviceGray(),
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue).rawValue
) else {
return nil
}
let path = CGPath(roundedRect: rect, cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil)
#if canImport(AppKit)
context.setFillColor(NSColor.white.cgColor)
#endif
#if canImport(UIKit)
context.setFillColor(UIColor.white.cgColor)
#endif
context.addPath(path)
context.closePath()
context.fillPath()
return context.makeImage()
}
}
#else
import UIKit

class RoundedSquareShape: Shape {
var rect: CGRect = .zero
var cornerRadius: CGFloat = .zero

func makeCGImage() -> CGImage? {
UIGraphicsBeginImageContext(rect.size)
guard let context = UIGraphicsGetCurrentContext() else {
return nil
}
let roundedPath = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius)
context.setFillColor(UIColor.white.cgColor)
context.addPath(roundedPath.cgPath)
context.closePath()
context.fillPath()
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return image?.cgImage
}
}
#endif
13 changes: 5 additions & 8 deletions Sources/Screen/ShapeFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,19 @@ final class ShapeFactory {
private var imageBuffers: [String: vImage_Buffer] = [:]
private var roundedSquareShape = RoundedSquareShape()

func cornerRadius(_ size: CGSize, cornerRadius: CGFloat) -> vImage_Buffer {
func cornerRadius(_ size: CGSize, cornerRadius: CGFloat) -> vImage_Buffer? {
let key = "\(size.width):\(size.height):\(cornerRadius)"
if let buffer = imageBuffers[key] {
return buffer
}
var imageBuffer = vImage_Buffer()
roundedSquareShape.rect = .init(origin: .zero, size: size)
roundedSquareShape.cornerRadius = cornerRadius
guard
let image = roundedSquareShape.makeCGImage(),
var format = vImage_CGImageFormat(cgImage: image),
vImageBuffer_InitWithCGImage(&imageBuffer, &format, nil, image, vImage_Flags(kvImageNoFlags)) == kvImageNoError else {
return imageBuffer
let image = roundedSquareShape.makeCGImage() else {
return nil
}
imageBuffers[key] = imageBuffer
return imageBuffer
imageBuffers[key] = try? vImage_Buffer(cgImage: image)
return imageBuffers[key]
}

func removeAll() {
Expand Down

0 comments on commit 6fa9231

Please sign in to comment.