238 lines
8.0 KiB
Swift
238 lines
8.0 KiB
Swift
import CoreMedia
|
|
import CoreVideo
|
|
import Foundation
|
|
|
|
struct PPGFrameColorMetrics: Sendable {
|
|
let timestampSeconds: Double
|
|
let redMean: Double
|
|
let greenMean: Double
|
|
let blueMean: Double
|
|
let redVariance: Double
|
|
let greenVariance: Double
|
|
let luminanceMean: Double
|
|
let saturationRatio: Double
|
|
}
|
|
|
|
enum PPGFrameMetricsExtractorError: Error {
|
|
case invalidTimestamp
|
|
case unsupportedPixelFormat(OSType)
|
|
case pixelBufferLockFailed(CVReturn)
|
|
case missingPlane
|
|
case missingBaseAddress
|
|
case emptyRegionOfInterest
|
|
}
|
|
|
|
/// Extracts compact color statistics from a central ROI without retaining or copying a frame.
|
|
final class PPGFrameMetricsExtractor {
|
|
private let gridSize: Int
|
|
private let roiFraction: Double
|
|
|
|
init(gridSize: Int = 32, roiFraction: Double = 0.55) {
|
|
self.gridSize = max(8, gridSize)
|
|
self.roiFraction = min(max(roiFraction, 0.2), 0.9)
|
|
}
|
|
|
|
func extract(from sampleBuffer: CMSampleBuffer) throws -> PPGFrameColorMetrics {
|
|
let timestamp = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sampleBuffer))
|
|
guard timestamp.isFinite, timestamp >= 0 else {
|
|
throw PPGFrameMetricsExtractorError.invalidTimestamp
|
|
}
|
|
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
|
|
throw PPGFrameMetricsExtractorError.missingBaseAddress
|
|
}
|
|
|
|
let lockFlags = CVPixelBufferLockFlags.readOnly
|
|
let lockStatus = CVPixelBufferLockBaseAddress(pixelBuffer, lockFlags)
|
|
guard lockStatus == kCVReturnSuccess else {
|
|
throw PPGFrameMetricsExtractorError.pixelBufferLockFailed(lockStatus)
|
|
}
|
|
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, lockFlags) }
|
|
|
|
let pixelFormat = CVPixelBufferGetPixelFormatType(pixelBuffer)
|
|
switch pixelFormat {
|
|
case kCVPixelFormatType_420YpCbCr8BiPlanarFullRange:
|
|
return try extractBiPlanarYUV(
|
|
from: pixelBuffer,
|
|
timestamp: timestamp,
|
|
isFullRange: true
|
|
)
|
|
case kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange:
|
|
return try extractBiPlanarYUV(
|
|
from: pixelBuffer,
|
|
timestamp: timestamp,
|
|
isFullRange: false
|
|
)
|
|
case kCVPixelFormatType_32BGRA:
|
|
return try extractBGRA(from: pixelBuffer, timestamp: timestamp)
|
|
default:
|
|
throw PPGFrameMetricsExtractorError.unsupportedPixelFormat(pixelFormat)
|
|
}
|
|
}
|
|
|
|
private func extractBiPlanarYUV(
|
|
from pixelBuffer: CVPixelBuffer,
|
|
timestamp: Double,
|
|
isFullRange: Bool
|
|
) throws -> PPGFrameColorMetrics {
|
|
guard CVPixelBufferGetPlaneCount(pixelBuffer) >= 2 else {
|
|
throw PPGFrameMetricsExtractorError.missingPlane
|
|
}
|
|
guard let yBase = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0),
|
|
let uvBase = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1)
|
|
else {
|
|
throw PPGFrameMetricsExtractorError.missingBaseAddress
|
|
}
|
|
|
|
let width = CVPixelBufferGetWidthOfPlane(pixelBuffer, 0)
|
|
let height = CVPixelBufferGetHeightOfPlane(pixelBuffer, 0)
|
|
let yStride = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0)
|
|
let uvStride = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 1)
|
|
let yBytes = yBase.assumingMemoryBound(to: UInt8.self)
|
|
let uvBytes = uvBase.assumingMemoryBound(to: UInt8.self)
|
|
let roi = regionOfInterest(width: width, height: height)
|
|
let stepX = max(roi.width / gridSize, 1)
|
|
let stepY = max(roi.height / gridSize, 1)
|
|
var accumulator = ColorAccumulator()
|
|
|
|
for y in stride(from: roi.minY, to: roi.maxY, by: stepY) {
|
|
for x in stride(from: roi.minX, to: roi.maxX, by: stepX) {
|
|
let yValue = Double(yBytes[y * yStride + x])
|
|
let uvOffset = (y / 2) * uvStride + (x / 2) * 2
|
|
let cb = Double(uvBytes[uvOffset])
|
|
let cr = Double(uvBytes[uvOffset + 1])
|
|
let rgb = convertYCbCrToRGB(y: yValue, cb: cb, cr: cr, isFullRange: isFullRange)
|
|
accumulator.add(red: rgb.red, green: rgb.green, blue: rgb.blue, luminance: yValue)
|
|
}
|
|
}
|
|
|
|
return try accumulator.result(timestamp: timestamp)
|
|
}
|
|
|
|
private func extractBGRA(
|
|
from pixelBuffer: CVPixelBuffer,
|
|
timestamp: Double
|
|
) throws -> PPGFrameColorMetrics {
|
|
guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else {
|
|
throw PPGFrameMetricsExtractorError.missingBaseAddress
|
|
}
|
|
let width = CVPixelBufferGetWidth(pixelBuffer)
|
|
let height = CVPixelBufferGetHeight(pixelBuffer)
|
|
let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)
|
|
let bytes = baseAddress.assumingMemoryBound(to: UInt8.self)
|
|
let roi = regionOfInterest(width: width, height: height)
|
|
let stepX = max(roi.width / gridSize, 1)
|
|
let stepY = max(roi.height / gridSize, 1)
|
|
var accumulator = ColorAccumulator()
|
|
|
|
for y in stride(from: roi.minY, to: roi.maxY, by: stepY) {
|
|
for x in stride(from: roi.minX, to: roi.maxX, by: stepX) {
|
|
let offset = y * bytesPerRow + x * 4
|
|
let blue = Double(bytes[offset])
|
|
let green = Double(bytes[offset + 1])
|
|
let red = Double(bytes[offset + 2])
|
|
let luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue
|
|
accumulator.add(red: red, green: green, blue: blue, luminance: luminance)
|
|
}
|
|
}
|
|
|
|
return try accumulator.result(timestamp: timestamp)
|
|
}
|
|
|
|
private func regionOfInterest(width: Int, height: Int) -> (
|
|
minX: Int,
|
|
maxX: Int,
|
|
minY: Int,
|
|
maxY: Int,
|
|
width: Int,
|
|
height: Int
|
|
) {
|
|
let roiWidth = max(Int(Double(width) * roiFraction), 1)
|
|
let roiHeight = max(Int(Double(height) * roiFraction), 1)
|
|
let minX = max((width - roiWidth) / 2, 0)
|
|
let minY = max((height - roiHeight) / 2, 0)
|
|
return (minX, minX + roiWidth, minY, minY + roiHeight, roiWidth, roiHeight)
|
|
}
|
|
|
|
private func convertYCbCrToRGB(
|
|
y: Double,
|
|
cb: Double,
|
|
cr: Double,
|
|
isFullRange: Bool
|
|
) -> (red: Double, green: Double, blue: Double) {
|
|
let red: Double
|
|
let green: Double
|
|
let blue: Double
|
|
|
|
if isFullRange {
|
|
let shiftedCb = cb - 128
|
|
let shiftedCr = cr - 128
|
|
red = y + 1.402 * shiftedCr
|
|
green = y - 0.344_136 * shiftedCb - 0.714_136 * shiftedCr
|
|
blue = y + 1.772 * shiftedCb
|
|
} else {
|
|
let shiftedY = max(y - 16, 0)
|
|
let shiftedCb = cb - 128
|
|
let shiftedCr = cr - 128
|
|
red = 1.164_383 * shiftedY + 1.596_027 * shiftedCr
|
|
green = 1.164_383 * shiftedY - 0.391_762 * shiftedCb - 0.812_968 * shiftedCr
|
|
blue = 1.164_383 * shiftedY + 2.017_232 * shiftedCb
|
|
}
|
|
|
|
return (clampByte(red), clampByte(green), clampByte(blue))
|
|
}
|
|
|
|
private func clampByte(_ value: Double) -> Double {
|
|
min(max(value, 0), 255)
|
|
}
|
|
}
|
|
|
|
private struct ColorAccumulator {
|
|
private(set) var count = 0
|
|
private var redMean = 0.0
|
|
private var greenMean = 0.0
|
|
private var blueMean = 0.0
|
|
private var luminanceMean = 0.0
|
|
private var redM2 = 0.0
|
|
private var greenM2 = 0.0
|
|
private var saturatedCount = 0
|
|
|
|
mutating func add(red: Double, green: Double, blue: Double, luminance: Double) {
|
|
count += 1
|
|
let n = Double(count)
|
|
|
|
let redDelta = red - redMean
|
|
redMean += redDelta / n
|
|
redM2 += redDelta * (red - redMean)
|
|
|
|
let greenDelta = green - greenMean
|
|
greenMean += greenDelta / n
|
|
greenM2 += greenDelta * (green - greenMean)
|
|
|
|
blueMean += (blue - blueMean) / n
|
|
luminanceMean += (luminance - luminanceMean) / n
|
|
// A fingertip lit by the torch commonly clips only the red channel while
|
|
// green still contains a clean pulsatile waveform. Count true optical
|
|
// clipping only when both useful color channels (or luminance) are clipped.
|
|
if (red >= 250 && green >= 250) || luminance >= 250 {
|
|
saturatedCount += 1
|
|
}
|
|
}
|
|
|
|
func result(timestamp: Double) throws -> PPGFrameColorMetrics {
|
|
guard count > 0 else {
|
|
throw PPGFrameMetricsExtractorError.emptyRegionOfInterest
|
|
}
|
|
let divisor = Double(max(count - 1, 1))
|
|
return PPGFrameColorMetrics(
|
|
timestampSeconds: timestamp,
|
|
redMean: redMean,
|
|
greenMean: greenMean,
|
|
blueMean: blueMean,
|
|
redVariance: redM2 / divisor,
|
|
greenVariance: greenM2 / divisor,
|
|
luminanceMean: luminanceMean,
|
|
saturationRatio: Double(saturatedCount) / Double(count)
|
|
)
|
|
}
|
|
}
|