Hoy cualquier reloj tiene sensor de pulso. Pero ¿y si no hay reloj? En el teléfono queda la cámara — y resulta que eso basta para estimar la frecuencia cardíaca: sin hardware externo, sin nube y sin un solo fotograma guardado. En MeteoHealth construí esta funcionalidad enteramente sobre frameworks del sistema — AVFoundation para la captura, Vision para la detección de la cara, Accelerate para el análisis espectral. Nada de modelos de ML: bajo el capó hay procesamiento digital de señales puro. A continuación, cómo funciona, con código real de producción, y — con honestidad — dónde están los límites del método.
Cómo un dedo se convierte en sensor#
El principio se llama fotopletismografía (PPG) — el mismo de los pulsioxímetros. Se coloca el dedo sobre la cámara trasera y el flash (torch) atraviesa la yema con su luz. La sangre absorbe la luz, y con cada latido del corazón cambia el llenado de los capilares — y con él, la cantidad de luz que llega al sensor. La cámara se convierte en un fotorreceptor: cada fotograma es una muestra de la señal.
De cada fotograma se toma la media del canal rojo sobre una región central de 64×64 — un recorrido directo por el búfer BGRA crudo, sin Core Image ni copias intermedias:
/// Media del canal ROJO sobre la región central de 64×64 (modo dedo). BGRA, R en +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
}En 25–30 segundos a 30 fps se acumula un búfer de aproximadamente 750–900 muestras. El fotograma se descarta justo después del promediado — el CMSampleBuffer no se almacena en ningún sitio y no aparece ningún archivo en disco. No es un efecto secundario, sino un invariante, fijado directamente en la cabecera del view model.
Toda la matemática vive en una estructura aparte, PPGProcessor, que no sabe nada ni de AVFoundation ni de SwiftUI — solo arrays de Float y marcas de tiempo. Gracias a eso, todo el pipeline está cubierto por tests unitarios: le pasas un seno sintético de 1.2 Hz y está obligado a devolver 72 BPM.
Un detalle aparte para quienes viven en Swift 6: AVCaptureSession y AVCaptureVideoDataOutput se configuran no en el MainActor, sino en una sessionQueue dedicada, por lo que bajo concurrencia estricta están marcados como nonisolated(unsafe) — con un comentario explícito de que el confinamiento lo garantiza la cola:
/// La sesión y el output se confinan en sessionQueue (begin/commitConfiguration,
/// start/stopRunning) — no en el MainActor, de ahí nonisolated(unsafe).
nonisolated(unsafe) let captureSession = AVCaptureSession()Tiempo no uniforme: para qué sirve el remuestreo#
Cada fotograma se convirtió en una muestra — pero no a intervalos de tiempo iguales. El paso menos obvio del pipeline no es la FFT, sino lo que va antes. La FFT presupone un muestreo uniforme, pero las marcas de tiempo de CMSampleBuffer fluctúan: el sistema descarta fotogramas, el intervalo entre fotogramas «respira». Si finges que las muestras son uniformes, el espectro se emborrona y el pico se desplaza. Por eso, lo primero es interpolar linealmente la señal sobre una rejilla uniforme de 30 Hz:
/// Interpolación lineal sobre una rejilla uniforme.
private func resample(samples: [PPGSample], to rate: Double) -> [Float] {Luego, los clásicos del DSP: el detrending restando una media móvil (~3 segundos) elimina la deriva lenta del brillo, y la ventana de Hamming amortigua los artefactos de los bordes. La puerta de longitud mínima también es honesta — no «cantidad de muestras», sino la duración real de la ventana, robusta ante fotogramas perdidos: un umbral de 8.5 segundos, que a 30 Hz da un mínimo de 256 puntos remuestreados para la FFT.
Un espectro en lugar de contar picos#
El camino ingenuo es contar los latidos como máximos locales en el dominio del tiempo. Con la señal ruidosa de una cámara eso se desmorona: un solo movimiento del dedo genera «latidos» que nunca existieron. La vía espectral es más robusta: una real-FFT vía vDSP (vDSP_fft_zrip → vDSP_zvmags) produce el espectro de potencia, y el pico se busca solo en la banda fisiológica de 0.7–4.0 Hz — es decir, 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))Todo lo que queda fuera de la banda — la respiración, el temblor de la mano, el parpadeo de la iluminación — no influye en la elección del pico. Pero encontrar el máximo no basta: hay que saber si es un pico real o un bulto aleatorio de ruido. Para eso se calcula el SNR — la relación entre el pico y la mediana de los bins vecinos de la misma banda (el propio pico, con un entorno de ±2 bins, se excluye):
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)
}Esta es la decisión de producto clave: si el SNR está por debajo del umbral, la app devuelve un error de «señal débil» y propone repetir — en lugar de mostrar un pulso inventado con cara de seguridad. Una medición mala rechazada con honestidad es mejor que un número bonito en el que no se puede confiar.
El toque final es la interpolación parabólica del vértice sobre tres bins. En una ventana corta la rejilla de la FFT es gruesa (~1.7 BPM por bin), y la interpolación da resolución de frecuencia sub-bin casi gratis.
La frente en el encuadre: rPPG vía Vision#
El segundo modo funciona sin dedo y sin flash: la cámara frontal mira a la cara (remote PPG). La pulsación de la sangre cambia sutilmente el tono de la piel, y esa respuesta se ve con más fuerza en el canal verde. Vision se usa aquí para exactamente una cosa — encontrar la cara; no hay ninguna inferencia de ML encima:
guard let face = request.results?.first,
face.boundingBox.width >= Self.minFaceWidth else {
return nil
}
// ROI «frente»: parte superior de la cara (Vision: eje Y de abajo arriba → arriba = Y mayor).
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
)El ROI se construye geométricamente a partir del bounding box: una franja con una altura del 20% de la cara a la altura de la frente, el 50% central del ancho — ahí la piel está descubierta y los ojos y el pelo molestan lo mínimo. La puerta minFaceWidth = 0.15 descarta la «cara lejos»: en una cara pequeña el ROI degenera en un puñado de píxeles.
Después viene el mismo recorrido por el búfer BGRA crudo bajo CVPixelBufferLockBaseAddress, solo que con el desplazamiento de canal +1 (verde) en lugar de +2, y la señal obtenida va al mismo PPGProcessor. La privacidad es la misma que en el modo dedo: del fotograma con la cara se extrae un solo número — la media del verde — y el fotograma muere.
Una salvedad: el rPPG es notablemente más caprichoso que el PPG de contacto. El movimiento de la cabeza, un cambio de iluminación, una sombra en la frente — todo eso es ruido del mismo orden que la propia señal. Salva la situación la misma puerta de SNR: una medición dudosa no pasa.
Estrés según Baevsky — con reservas#
La misma señal da algo más que el pulso. De la señal detrendada en el dominio del tiempo se extraen los intervalos RR (las distancias entre latidos), y de ellos, el índice de tensión de Baevsky (Baevsky Stress Index): SI = AMo / (2·Mo·MxDMn) sobre un histograma de RR con un bin de 50 ms. La fórmula es clásica, pero su aplicación viene con una advertencia honesta escrita directamente en el código fuente:
// ATENCIÓN: el Baevsky clásico se calcula sobre una grabación de 5 min (100+ latidos). En una ventana corta
// de PPG (~15 s, ~15–20 latidos) el valor es INDICATIVO — sirve para la tendencia, no para el diagnóstico.La metodología clásica exige una grabación de cinco minutos; sobre ~15 segundos de PPG el índice es un indicador para observar la tendencia, nada más. Si hay menos de 12 intervalos RR válidos, el calculador devuelve nil — de nuevo, un rechazo en lugar de una conjetura.
Cuatro cosas que entendí sobre la señal de la cámara#
Primero: la cámara ya es un sensor, y entre el «búfer crudo de fotogramas» y el «pulso» no hay magia, solo una cadena de señal: una muestra por fotograma → remuestreo a una rejilla uniforme → detrend → ventana → FFT → pico en la banda fisiológica. Segundo: el remuestreo es el paso que más a menudo se omite, y es precisamente lo que distingue un pipeline de FFT que funciona de uno bonito pero mentiroso. Tercero: la calidad de la señal es parte del API, no un adorno: la puerta de SNR y las puertas de duración y de tamaño de la cara convierten el «a veces muestra tonterías» en «o un número en el que se puede confiar, o un rechazo explícito». Y cuarto, el marco: todo esto es una estimación de wellness, no una medición médica. El método es sensible al movimiento y a la iluminación, y lo más maduro que puede hacer una funcionalidad así es conocer sus límites y decírselos al usuario con franqueza.



