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 }