pulse-monitor/processor.go

154 lines
4.1 KiB
Go

package main
import (
"fmt"
"io"
"time"
"gocv.io/x/gocv"
)
// Processor handles the frame processing pipeline
type Processor struct {
config *Config
logger io.Writer
}
// NewProcessor creates a new processor
func NewProcessor(config *Config, logger io.Writer) *Processor {
return &Processor{
config: config,
logger: logger,
}
}
// processFrame runs OCR on a normalized frame with valid layout
// Frame is already rotated and scaled by the caller
func (p *Processor) processFrame(frame gocv.Mat, rawFrame gocv.Mat, frameNum int, state *ProcessingState, timing *TimingData) ProcessingResult {
timestamp := time.Now().Format("15:04:05.000")
frameStartTime := time.Now()
// Extract display regions
spo2Region := frame.Region(state.Layout.SpO2Area)
spo2Img := spo2Region.Clone()
spo2Region.Close()
hrRegion := frame.Region(state.Layout.HRArea)
hrImg := hrRegion.Clone()
hrRegion.Close()
// Check if images match previous - skip OCR if identical (uses image hash cache)
if state.HasPrevImages {
if imagesMatch(spo2Img, state.PrevSpO2Img) && imagesMatch(hrImg, state.PrevHRImg) {
spo2Img.Close()
hrImg.Close()
timing.Total = time.Since(frameStartTime).Milliseconds()
return ProcessingResult{
Status: StatusNoChange,
Reading: Reading{
SpO2: state.PrevSpO2Result,
HR: state.PrevHRResult,
Timestamp: timestamp,
FrameNum: frameNum,
},
}
}
}
// Store current images for next comparison
if state.HasPrevImages {
state.PrevSpO2Img.Close()
state.PrevHRImg.Close()
}
state.PrevSpO2Img = spo2Img.Clone()
state.PrevHRImg = hrImg.Clone()
state.HasPrevImages = true
// Run Gemini OCR on both displays
spo2Start := time.Now()
spo2Val, spo2Err := GeminiOCR(spo2Img, "SpO2")
timing.OCR_SpO2 = time.Since(spo2Start).Milliseconds()
hrStart := time.Now()
hrVal, hrErr := GeminiOCR(hrImg, "HR")
timing.OCR_HR = time.Since(hrStart).Milliseconds()
spo2Img.Close()
hrImg.Close()
// Handle OCR errors
if spo2Err != nil || hrErr != nil {
errMsg := ""
if spo2Err != nil {
errMsg += fmt.Sprintf("SpO2: %v ", spo2Err)
}
if hrErr != nil {
errMsg += fmt.Sprintf("HR: %v", hrErr)
}
logMessage(Both, Warning, " ⚠️ OCR error: %s", errMsg)
timing.Total = time.Since(frameStartTime).Milliseconds()
return ProcessingResult{
Status: StatusLowConfidence,
Reading: Reading{
SpO2: spo2Val,
HR: hrVal,
Timestamp: timestamp,
FrameNum: frameNum,
},
}
}
// Store results for duplicate detection
state.PrevSpO2Result = spo2Val
state.PrevHRResult = hrVal
reading := Reading{
SpO2: spo2Val,
HR: hrVal,
Timestamp: timestamp,
FrameNum: frameNum,
}
// Check for physiologically impossible values
if reading.SpO2 < 50 || reading.SpO2 > 100 || reading.HR < 30 || reading.HR > 220 {
logMessage(Both, Warning, " ⚠️ Invalid values: SpO2=%d, HR=%d - skipping", reading.SpO2, reading.HR)
timing.Total = time.Since(frameStartTime).Milliseconds()
return ProcessingResult{Status: StatusCorrupted, Reading: reading, ShouldPost: false}
}
// Check if values changed from last posted
if reading.SpO2 == state.LastPosted.SpO2 && reading.HR == state.LastPosted.HR {
timing.Total = time.Since(frameStartTime).Milliseconds()
return ProcessingResult{Status: StatusNoChange, Reading: reading}
}
// Values changed - update state and post
state.LastPosted = reading
timing.Total = time.Since(frameStartTime).Milliseconds()
logMessage(Console, Info, "📊 SpO2: %d%% HR: %d bpm", reading.SpO2, reading.HR)
return ProcessingResult{Status: StatusSuccess, Reading: reading, ShouldPost: true}
}
// postReading posts a reading to Home Assistant
func (p *Processor) postReading(reading *Reading, state *ProcessingState) error {
if p.config == nil {
logMessage(LogFile, Info, " ⓘ Skipping HASS post (test mode)")
return nil
}
spo2Err := postToHomeAssistant(p.config, "sensor.pulse_ox_spo2", reading.SpO2, "%", "SpO2")
hrErr := postToHomeAssistant(p.config, "sensor.pulse_ox_hr", reading.HR, "bpm", "Heart Rate")
if spo2Err == nil && hrErr == nil {
state.SuccessCount++
return nil
}
state.FailureCount++
if spo2Err != nil {
return spo2Err
}
return hrErr
}