package main import ( "fmt" "time" "gocv.io/x/gocv" ) // Global DEBUG flag for detection.go (set by pulseox-monitor.go) var DEBUG = false // ProcessingStatus represents the outcome of processing a frame type ProcessingStatus int const ( StatusSuccess ProcessingStatus = iota StatusCorrupted StatusLowConfidence StatusNoChange StatusUnstable ) // Reading holds SpO2 and HR values with confidence scores type Reading struct { SpO2 int SpO2LeftDigit int SpO2LeftConf float64 SpO2RightDigit int SpO2RightConf float64 HR int HRLeftDigit int HRLeftConf float64 HRRightDigit int HRRightConf float64 Timestamp string FrameNum int } // AvgConfidence returns average confidence for SpO2 and HR func (r *Reading) AvgConfidence() (spo2Avg, hrAvg float64) { spo2Avg = (r.SpO2LeftConf + r.SpO2RightConf) / 2.0 hrAvg = (r.HRLeftConf + r.HRRightConf) / 2.0 return } // IsCorrupted checks if any digit is marked as invalid (-1) func (r *Reading) IsCorrupted() bool { return r.SpO2LeftDigit == -1 || r.SpO2RightDigit == -1 || r.HRLeftDigit == -1 || r.HRRightDigit == -1 } // GetCorruptionDetails returns a description of which digit(s) are corrupted func (r *Reading) GetCorruptionDetails() string { corrupted := []string{} if r.SpO2LeftDigit == -1 { corrupted = append(corrupted, "SpO2 digit 2") } if r.SpO2RightDigit == -1 { corrupted = append(corrupted, "SpO2 digit 3") } if r.HRLeftDigit == -1 { corrupted = append(corrupted, "HR digit 2") } if r.HRRightDigit == -1 { corrupted = append(corrupted, "HR digit 3") } if len(corrupted) == 0 { return "unknown" } result := "" for i, c := range corrupted { if i > 0 { result += ", " } result += c } return result } // ProcessingResult contains the outcome of processing a single frame type ProcessingResult struct { Status ProcessingStatus Reading Reading ShouldPost bool UnstableReason string } // ProcessingState tracks all state across frame processing type ProcessingState struct { // Detection state (sentinels: rotation=9999.0, scaleFactor=0.0) LockedRotation float64 // Rotation angle in degrees (9999 = not detected) LockedScale float64 // Scale factor (0 = not detected) Layout *ScreenLayout // SpO2 and HR display areas LayoutValid bool // True when detection has succeeded // Change detection LastPosted Reading // Stability tracking LastReading Reading HasLastReading bool PendingReading *Reading BaselineReading Reading // Retry state InRetry bool RetryCount int MaxRetries int ConsecutiveFailures int // Track consecutive layout/processing failures LowConfStage int // Low confidence retry stage: 0=none, 1=fresh frame, 2=redetect, 3=10s, 4=30s, 5+=60s LowConfWaitUntil time.Time // When to resume after low confidence wait DetectFailStage int // Detection failure stage: 0=none, 1-3=immediate, 4=10s, 5=30s, 6+=60s DetectWaitUntil time.Time // When to resume after detection wait // Statistics FrameCount int ProcessedCount int SuccessCount int FailureCount int LowConfidenceCount int TimestampCheckCounter int // Check timestamp every 10 processed frames // Review entries ReviewEntries []ReviewEntry // Timing tracking TimingFrameCount int // Count frames for periodic header // Previous display images for duplicate detection PrevSpO2Img gocv.Mat PrevHRImg gocv.Mat PrevSpO2Result int PrevHRResult int PrevSpO2Conf float64 PrevHRConf float64 HasPrevImages bool // Notification flood protection NotifiedMissingTemplates map[int]bool // Templates we've already warned about NotifiedDetectionFailure bool // Already notified about detection failure DetectionFailStart time.Time // When detection failures started // OHLC ticker display TickStart time.Time // Start of current tick period TickDuration time.Duration // How long each tick lasts (default 5 min) SpO2Open int // First SpO2 in tick SpO2High int // Highest SpO2 in tick SpO2Low int // Lowest SpO2 in tick SpO2Close int // Current/last SpO2 HROpen int // First HR in tick HRHigh int // Highest HR in tick HRLow int // Lowest HR in tick HRClose int // Current/last HR TickHasData bool // Whether we have any data in current tick } // NewProcessingState creates initial state func NewProcessingState() *ProcessingState { return &ProcessingState{ LockedRotation: 9999.0, // Sentinel: not detected LockedScale: 0.0, // Sentinel: not detected LayoutValid: false, MaxRetries: 1, LastPosted: Reading{SpO2: -1, HR: -1}, LastReading: Reading{SpO2: -1, HR: -1}, BaselineReading: Reading{SpO2: -1, HR: -1}, NotifiedMissingTemplates: make(map[int]bool), TickDuration: 5 * time.Minute, } } // UpdateOHLC updates the OHLC ticker values and prints the ticker line func (s *ProcessingState) UpdateOHLC(spo2, hr int) { now := time.Now() // Check if we need to start a new tick if s.TickStart.IsZero() || now.Sub(s.TickStart) >= s.TickDuration { // Print newline if we had data in previous tick if s.TickHasData { fmt.Println() // Move to new line } // Start new tick s.TickStart = now s.SpO2Open, s.SpO2High, s.SpO2Low, s.SpO2Close = spo2, spo2, spo2, spo2 s.HROpen, s.HRHigh, s.HRLow, s.HRClose = hr, hr, hr, hr s.TickHasData = true } else { // Update existing tick s.SpO2Close = spo2 s.HRClose = hr if spo2 > s.SpO2High { s.SpO2High = spo2 } if spo2 < s.SpO2Low { s.SpO2Low = spo2 } if hr > s.HRHigh { s.HRHigh = hr } if hr < s.HRLow { s.HRLow = hr } } // Print ticker line (overwrite current line) timestamp := now.Format("15:04:05") fmt.Printf("\r%s SpO2: %d/%d/%d/%d HR: %d/%d/%d/%d ", timestamp, s.SpO2Open, s.SpO2High, s.SpO2Low, s.SpO2Close, s.HROpen, s.HRHigh, s.HRLow, s.HRClose) } // ResetDetection resets detection state to trigger re-detection func (s *ProcessingState) ResetDetection() { s.LockedRotation = 9999.0 s.LockedScale = 0.0 s.LayoutValid = false s.Layout = nil } // NeedsDetection returns true if detection needs to run func (s *ProcessingState) NeedsDetection() bool { return s.LockedRotation == 9999.0 || s.LockedScale == 0.0 || !s.LayoutValid } // Action represents what to do next type Action int const ( ActionContinue Action = iota // Continue to next frame ActionRetry // Retry with next frame + layout re-detection ActionPost // Post reading to Home Assistant ActionHold // Hold reading for validation ActionDiscard // Discard reading ) // TimingData holds timing measurements for a single frame type TimingData struct { FrameNum int Acquire int64 // Frame acquisition (ms) Threshold int64 // Grayscale + threshold (ms) Preprocess int64 // Crop + rotate (ms) Scale int64 // Frame scaling (ms) OCR_SpO2 int64 // SpO2 recognition (ms) OCR_HR int64 // HR recognition (ms) Validation int64 // Validation checks (ms) FileIO int64 // Saving review images (ms) HASS int64 // Home Assistant POST (ms) Total int64 // Total processing time (ms) }