Датчик пульса сегодня есть в любых часах. А если часов нет? В телефоне остаётся камера — и оказывается, этого достаточно, чтобы оценить частоту сердечных сокращений: без внешнего железа, без облака и без единого сохранённого кадра. В MeteoHealth я собрал такую фичу целиком на системных фреймворках — AVFoundation для захвата, Vision для детекции лица, Accelerate для спектрального анализа. Никаких ML-моделей: под капотом чистая цифровая обработка сигналов. Ниже — как это устроено, с реальным кодом из продакшена, и честно — где у метода пределы.
Как палец становится датчиком#
Принцип называется фотоплетизмография (PPG) — тот же, что в пульсоксиметрах. Палец кладётся на заднюю камеру, вспышка (torch) просвечивает подушечку насквозь. Кровь поглощает свет, и с каждым ударом сердца наполнение капилляров меняется — а с ним и количество света, дошедшего до сенсора. Камера превращается в фотоприёмник: каждый кадр — это один сэмпл сигнала.
Из кадра берётся среднее красного канала по центральной области 64×64 — прямой проход по сырому BGRA-буферу, без Core Image и промежуточных копий:
/// Среднее КРАСНОГО по центральной области 64×64 (режим пальца). BGRA, R на +2.
private nonisolated static func meanRedCenter(of pixelBuffer: CVPixelBuffer) -> Float? {
CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) }
guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else { return nil }
// ...
var redSum: UInt64 = 0
for y in startY ..< startY + cropSize {
let rowStart = y * bytesPerRow
for x in startX ..< startX + cropSize {
redSum &+= UInt64(bytes[rowStart + x * 4 + 2])
}
}
return Float(redSum) / Float(cropSize * cropSize) / 255.0
}За 25–30 секунд при 30 fps набирается буфер примерно из 750–900 сэмплов. Кадр после усреднения сразу выбрасывается — CMSampleBuffer нигде не хранится, файлов на диске не появляется. Это не побочный эффект, а инвариант, зафиксированный прямо в шапке view-model.
Вся математика живёт в отдельной структуре PPGProcessor, которая не знает ни про AVFoundation, ни про SwiftUI — только массивы Float и временные метки. Благодаря этому весь пайплайн покрывается unit-тестами: подаёшь синтетический синус 1.2 Гц — обязан получить 72 BPM.
Отдельная деталь для тех, кто живёт в Swift 6: AVCaptureSession и AVCaptureVideoDataOutput конфигурируются не на MainActor, а на выделенной sessionQueue, поэтому в строгой конкурентности они помечены nonisolated(unsafe) — с явным комментарием, что конфайнмент обеспечивается очередью:
/// Сессия и output конфайнятся на sessionQueue (begin/commitConfiguration,
/// start/stopRunning) — не на MainActor, отсюда nonisolated(unsafe).
nonisolated(unsafe) let captureSession = AVCaptureSession()Неравномерное время: зачем ресемплинг#
Каждый кадр стал сэмплом — но не через равные промежутки времени. Самый неочевидный шаг пайплайна — не FFT, а то, что идёт перед ним. FFT предполагает равномерную дискретизацию, а таймстемпы CMSampleBuffer плавают: система дропает кадры, межкадровый интервал «дышит». Если сделать вид, что сэмплы равномерны, спектр размажется и пик уедет. Поэтому первым делом сигнал линейно интерполируется на равномерную сетку 30 Гц:
/// Линейная интерполяция в равномерную сетку.
private func resample(samples: [PPGSample], to rate: Double) -> [Float] {Дальше классика DSP: детренд вычитанием скользящего среднего (~3 секунды) убирает медленный дрейф яркости, окно Хэмминга гасит краевые артефакты. Гейт по минимальной длине тоже честный — не «количество сэмплов», а фактическая длительность окна, устойчивая к дропнутым кадрам: порог 8.5 секунды, что при 30 Гц даёт минимум 256 ресемплированных точек для FFT.
Спектр вместо подсчёта пиков#
Наивный путь — считать удары как локальные максимумы во временной области. На зашумлённом камерном сигнале это разваливается: одно движение пальца рождает «удары», которых не было. Спектральный путь устойчивее: real-FFT через vDSP (vDSP_fft_zrip → vDSP_zvmags) даёт спектр мощности, и дальше пик ищется только в физиологической полосе 0.7–4.0 Гц — это 42–240 BPM:
let bandStart = Int((Self.minBPM / 60.0) * Double(paddedLength) / Self.resamplingRate)
let bandEnd = min(spectrum.count - 1,
Int((Self.maxBPM / 60.0) * Double(paddedLength) / Self.resamplingRate))Всё вне полосы — дыхание, дрожание руки, мерцание освещения — на выбор пика не влияет. Но найти максимум мало: нужно понять, пик это или случайный бугор шума. Для этого считается SNR — отношение пика к медиане соседних бинов той же полосы (сам пик с окрестностью ±2 бина исключается):
floorBins.sort()
let floorValue = floorBins.isEmpty ? 1e-9 : Double(floorBins[floorBins.count / 2])
let snr = max(1.0, Double(peakValue) / max(floorValue, 1e-9))
guard snr >= Self.minSNR else {
return .failure(.lowSignalQuality)
}Это ключевое продуктовое решение: если SNR ниже порога, приложение возвращает ошибку «слабый сигнал» и предлагает повторить — вместо того чтобы показать выдуманный пульс с уверенным лицом. Плохой замер, который честно отбракован, лучше красивого числа, которому нельзя верить.
Последний штрих — параболическая интерполяция вершины по трём бинам. На коротком окне сетка FFT грубая (~1.7 BPM на бин), а интерполяция даёт суб-бинное разрешение частоты почти бесплатно.
Лоб в кадре: rPPG через Vision#
Второй режим — без пальца и без вспышки: фронтальная камера смотрит на лицо (remote PPG). Пульсация крови чуть-чуть меняет оттенок кожи, и сильнее всего этот отклик виден в зелёном канале. Vision здесь используется ровно для одного — найти лицо; никакого ML-инференса поверх:
guard let face = request.results?.first,
face.boundingBox.width >= Self.minFaceWidth else {
return nil
}
// ROI «лоб»: верхняя часть лица (Vision: ось Y снизу вверх → верх = больший Y).
let bb = face.boundingBox
let roi = CGRect(
x: bb.minX + bb.width * 0.25,
y: bb.minY + bb.height * 0.68,
width: bb.width * 0.5,
height: bb.height * 0.20
)ROI строится геометрией от bounding box: полоса высотой 20% лица на уровне лба, центральные 50% ширины — там кожа открыта и меньше всего мешают глаза и волосы. Гейт minFaceWidth = 0.15 отсекает «лицо далеко»: на мелком лице ROI вырождается в горстку пикселей.
Дальше — тот же проход по сырому BGRA-буферу под CVPixelBufferLockBaseAddress, только смещение канала +1 (зелёный) вместо +2, и полученный сигнал уходит в тот же PPGProcessor. Приватность здесь та же, что и в режиме пальца: из кадра с лицом извлекается одно число — среднее зелёного, — и кадр умирает.
Оговорка: rPPG заметно капризнее контактного PPG. Движение головы, смена освещения, тень на лбу — всё это шум того же порядка, что и сам сигнал. Спасает всё тот же SNR-гейт: сомнительный замер не проходит.
Стресс по Баевскому — с оговоркой#
Тот же сигнал даёт не только пульс. Из детрендированного сигнала во временной области извлекаются RR-интервалы (расстояния между ударами), а из них — индекс напряжения Баевского: SI = AMo / (2·Mo·MxDMn) по гистограмме RR с бином 50 мс. Формула классическая, но применение — с честной оговоркой, которая написана прямо в исходнике:
// ВНИМАНИЕ: классический Баевский считается по 5-мин записи (100+ ударов). На коротком PPG-окне
// (~15 с, ~15–20 ударов) значение ИНДИКАТИВНОЕ — годится для тренда, не для диагностики.Классическая методика требует пятиминутной записи; на ~15 секундах PPG индекс — индикатор для наблюдения за трендом, не более. Если валидных RR-интервалов меньше 12, калькулятор возвращает nil — снова отказ вместо догадки.
Четыре вещи, которые я понял про сигнал с камеры#
Первое: камера — это уже датчик, и между «сырым буфером кадров» и «пульсом» нет магии, только сигнальная цепочка: сэмпл на кадр → ресемплинг на равномерную сетку → детренд → окно → FFT → пик в физиологической полосе. Второе: ресемплинг — тот шаг, который чаще всего пропускают, и именно он отличает работающий FFT-пайплайн от красивого, но врущего. Третье: качество сигнала — часть API, а не украшение: SNR-гейт, гейты по длительности и по размеру лица превращают «иногда показывает ерунду» в «либо число, которому можно верить, либо явный отказ». И четвёртое, рамочное: всё это — wellness-оценка, не медицинское измерение. Метод чувствителен к движению и освещению, и самое взрослое, что может сделать такая фича — знать свои пределы и говорить о них пользователю прямо.



