154 lines
4.1 KiB
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
|
|
}
|