Ham veri, dokunulmamış hâliyle çoğu zaman kötü görünür. Grafiğe döktüğünüzde gördüğünüz şey düzgün bir sinyal değil, titreyen bir çizgidir. Bir yerlerde bir anlam vardır ama o anlamın üstü her türlü gürültüyle örtülmüştür: sensörün kendi iç gürültüsü, elin istemsiz titremesi, çevredeki manyetik alanlar, hatta sıcaklık. Ham veriyle çalışmak aslında bu örtüyü dikkatlice sıyırıp altındakini ortaya çıkarma işidir.
Immerza üzerinde çalışırken kendime küçük bir soru sordum: Meta Quest controller'ının içindeki IMU — yani jiroskop ve ivmeölçer — kullanıcının nefesini takip edebilir mi? Meditasyon uygulamasında kullanıcıya "nefes al, nefes ver" demek kolay; ama gerçekten nefesini sayabilirsek deneyim tamamen başka bir yere gider. Ekstra bir donanım yok. Bileklik yok, göğüs bandı yok. Sadece kullanıcının zaten elinde tuttuğu controller.
Bu yazıda o çalışmayı anlatacağım. Bu aslında küçük bir deney olarak başlamadı — doğrudan Immerza içinde prod ortamına entegre olması hedeflenen bir modüldü. Şu an çalışıyor ve iç testlerde yüksek doğruluk veriyor. Henüz tıbbi bir spirometre veya solunum monitörüyle yan yana koyup klinik bir kıyas yapmadım; o yüzden "medikal düzeyde doğru" değil ama "meditasyon sahnesini nefesine bağlayabilecek kadar doğru" diyebilirim.
Önce Sinyal Var mı?
En saf hâliyle başladım: controller'ı göğsümün üstüne yasladım, 60 saniye boyunca 72 Hz'de ivmeölçer ve jiroskop verisini Unity tarafında bir CSV'ye yazdırdım, sonra bunu Unity'nin içinde kendi yazdığım küçük bir debug görselleyiciyle çizdirdim. İlk grafik tam bir kaostu. Üç eksende de dalgalanma var, ama bu dalgalanmanın nefesle mi yoksa kalp atışıyla mı yoksa kasımın titremesiyle mi ilgili olduğunu anlamak imkânsızdı.
Burada şu basit gerçeği hatırladım: nefes yavaş bir olaydır. Normal bir insan dakikada 12–20 kez nefes alır. Yani 0.2 ile 0.33 Hz arası. Kalp atışı çok daha hızlı (1–1.5 Hz), el titremesi ondan da hızlı (8–12 Hz), sensör gürültüsü ise her yere dağılmış. Bu frekansları birbirinden ayırabilirsem, tek başına nefesi izole edebilirim.
Demek ki asıl iş: doğru frekansları geçirecek, diğer her şeyi ezecek bir filtre kurmak.
Filtre Zinciri
Sinyal işleme dünyasında bunun adı band-pass filter. İki frekans arası bir pencere açıyorsun, o pencerenin dışındaki her şeyi söndürüyorsun. Nefes için makul bir pencere: 0.1 Hz ile 0.5 Hz. Altında duran durum (DC bileşeni, postür kayması) kalıyor, üstünde kalp atışı ve titreme kalıyor, ortada sadece nefes.
İlk yaklaşım klasik bir Butterworth band-pass oldu. Unity'de hazır bir sinyal işleme kütüphanesi olmadığı için filter tasarımını — yani katsayıların hesaplanmasını — kendim C# içinde yazdım. İkinci dereceden iki biquad'ı zincirleyen basit bir IIR yapısı; her biri kendi state'ini tutan küçük bir struct. Böylece her frame yeni bir sample geldiğinde geçmiş iki sample ile yeni çıktıyı hesaplamak birkaç çarpma ve toplamadan ibaret.
Sonuç? Debug görselleyicide net bir dalga görünmeye başladı. Göğsümün kalkıp inmesi artık ayırt edilebilir bir sinüzoidal şekle dönüşmüştü. İlk kez "bu iş olacak" dediğim andı.
Filtrenin causal olmasına özellikle dikkat ettim; yani sadece geçmişe bakan, geleceği beklemeyen bir yapı. VR uygulamasında geleceği bekleyemezsin; nefesi kullanıcı nefesini tutarken anlaman gerekir.
İkinci katman olarak bir Savitzky-Golay smoother ekledim. Bu filtrenin güzelliği, sinyalin tepelerini ezmeden gürültüyü temizlemesi. Nefes dalgasının tepe noktası = soluk alma bitti, vadi noktası = soluk verme bitti — bu noktaları kaybetmek istemiyordum. Savitzky-Golay'i de küçük bir circular buffer üstünde, sabit pencere uzunluğunda (25 sample), C# içinde bir helper struct olarak yazdım.
Üçüncü katman tamamen pragmatik bir hamleydi: adaptif eşikleme. Herkesin nefes genliği farklı; bazıları derin nefes alıyor, bazıları yüzeysel. Sabit bir eşik koyarsan yüzeysel nefes alan kullanıcıları kaybedersin. Son 10 saniyenin RMS'ine göre eşiği sürekli güncelleyen bir katman ekledim. Artık "güçlü nefes" ile "zayıf nefes" farklı insanlarda aynı doğrulukla sayılıyordu.
Peak Detection ve Nefes Sayımı
Filtreli sinyalden nefes sayısını çıkarmak artık bir peak detection problemi. Ama burada naif yaklaşmak tehlikeli: tek bir gürültü tepesi yanlış bir nefes olarak sayılırsa, toplam count bozulur.
Şu üç kuralı koydum:
- İki tepe arasında en az 1.5 saniye olmalı (çünkü kimse dakikada 40 kez nefes almaz).
- Tepe genliği adaptif eşiği geçmeli.
- Tepeyi bir vadi takip etmeli. Yani sadece yukarı değil, aşağı hareketi de görmeden onu nefes saymıyorum.
Bu üç kural birleşince false positive oranı belirgin şekilde düştü. 60 saniyelik iç testlerde manuel saydığım nefes sayısını algoritma yüksek doğrulukla yakalıyordu. Vurgulamam gereken şey şu: doğruluk ölçümünü hâlâ manuel referans üzerinden yapıyorum; tıbbi bir solunum monitörüyle paralel ölçüm yapıp kıyas almak bir sonraki adım.
Unity'de Çalışma Düzeni
Tüm sistem MonoBehaviour olarak değil, tek bir BreathTracker sınıfı altında kurulu. İçinde üç temel bileşen var:
BiquadBandPass— iki biquad'lı IIR band-pass.SavGolSmoother— sabit katsayılı bir smoothing kernel'ı ve circular buffer.AdaptivePeakDetector— RMS takibi, cooldown ve valley-confirmation mantığı.
Update() metodunda OVRInput.GetLocalControllerAcceleration ile yeni ivmeölçer sample'ı alınıyor, pipeline'dan geçiriliyor, peak detector bir nefes yakaladığında OnBreathDetected event'i fırlatıyor. Meditasyon sahnesindeki görsel animasyonlar bu event'e abone.
İki dikkat noktası vardı.
Birincisi performans. Unity'de her frame ortalama 13 ms'in altında kalmalı (72 fps hedefi). Filtre zincirini circular buffer üstünde çalışacak şekilde kurdum; her yeni sample geldiğinde tüm diziyi yeniden işlemek yerine sadece yeni sample ile son birkaç state'i çarpmak gerekiyor. Koroutin kullanmadım, çünkü IMU verisi zaten OVRInput üzerinden frame başına geliyor — ayrı bir thread ya da zamanlayıcı gereksizdi.
İkincisi kullanıcı konumu. Controller'ı göğse bastırmak zorunda değilsin; kucağında tutsan da, hafifçe göğsüne yaslasan da çalışıyor, çünkü nefesin yarattığı torso hareketi zaten düşük frekanslı ve nispeten geniş alana yayılıyor. Ama kullanıcı kolunu şiddetli hareket ettirdiğinde sinyal bozuluyor. Bunu tespit etmek için ikinci bir RMS detektörü ekledim: jiroskop gücü eşiği aşarsa "büyük hareket" olarak işaretlenip nefes sayımı o pencerede duraklatılıyor.
Şu an nerede
Modül, Immerza'nın meditasyon sahnelerinden birinde canlı çalışıyor. Kullanıcı sahneye girdiğinde görsel animasyon kullanıcının gerçek nefes ritmine bağlanıyor. Su yüzeyi onun soluk almasıyla kalkıyor, soluk vermesiyle iniyor. Küçük bir detay — ama denediğim herkes ilk iki dakika içinde "nasıl yapıyor bunu?" diye sordu. İşte o sorunun sorulduğu an, sessizce mutlu olduğum an.
Mevcut durumda doğruluk, meditasyon deneyimini taşımaya fazlasıyla yetiyor. Ama eğer bir gün bu modülü biyolojik veri ürettiğini iddia eden bir ürünün parçası yapacak olursam — mesela kullanıcıya "bugün dakikada 14 nefes aldın" gibi bir metrik sunsam — o zaman tıbbi bir referans cihazıyla paralel kayıt alıp Bland-Altman analizi yapmak kaçınılmaz olur. Şimdilik oraya ihtiyaç yok; sahne anlık olarak doğru tepki verdiği sürece kullanıcı için yeterli.
Çıkardığım Genel Ders
Ham veri asla doğrudan kullanılmaz, ama aynı zamanda ondan tamamen kopup hazır kütüphanelere de kaçılmaz. Arada bir yerde, sinyalin ne olduğunu anlamaya çalışan, frekansları düşünen, kullanıcının fiziksel durumunu tahmin etmeye çalışan bir katman var. O katmanı kimse senin için yazmıyor. Üstelik Unity gibi ortamlarda hazır sinyal işleme kütüphaneleri de çoğu zaman yok — filtreni kendin yazıyorsun, katsayıları kendin üretiyorsun, debug görselleyicini kendin kuruyorsun.
Bir de şu: bazen en basit donanım, beklenmedik bir işi yapabilir. Controller'ın IMU'su sadece el takibi için var sanıyordum. Oysa içinde küçük bir solunum monitörü de varmış — sadece kimsenin ona öyle bakması gerekmemişti.
Bir sonraki adım: kalp atış hızını aynı controller'dan çıkarabilir miyim? Cevap muhtemelen "hayır, IMU çok gürültülü" ama bu bile başlı başına ilginç bir yazı konusu.