252 lines
7.3 KiB
Go
252 lines
7.3 KiB
Go
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)
|
|
}
|