package main import ( "fmt" "image" "io" "time" "gocv.io/x/gocv" ) // Processor handles the frame processing pipeline type Processor struct { templates map[int][]gocv.Mat config *Config logger io.Writer } // NewProcessor creates a new processor func NewProcessor(templates map[int][]gocv.Mat, config *Config, logger io.Writer) *Processor { return &Processor{ templates: templates, config: config, logger: logger, } } // buildReviewEntry creates a ReviewEntry from a Reading func buildReviewEntry(reading *Reading, failed bool, failureReason string, unstable bool, unstableReason string) ReviewEntry { return ReviewEntry{ FrameNum: reading.FrameNum, Timestamp: reading.Timestamp, SpO2Value: reading.SpO2, SpO2LeftDigit: reading.SpO2LeftDigit, SpO2LeftConf: reading.SpO2LeftConf, SpO2RightDigit: reading.SpO2RightDigit, SpO2RightConf: reading.SpO2RightConf, HRValue: reading.HR, HRLeftDigit: reading.HRLeftDigit, HRLeftConf: reading.HRLeftConf, HRRightDigit: reading.HRRightDigit, HRRightConf: reading.HRRightConf, Failed: failed, FailureReason: failureReason, Unstable: unstable, UnstableReason: unstableReason, } } // handleFrameFailure handles all failed frame processing: // - Saves debug files (raw frame + layout visualization) // - Creates and appends review entry // - Calculates total timing // - Returns ProcessingResult with given status func handleFrameFailure( rawFrame gocv.Mat, normalized gocv.Mat, layout *ScreenLayout, reading *Reading, status ProcessingStatus, failureReason string, state *ProcessingState, timing *TimingData, frameStartTime time.Time, ) ProcessingResult { // Save debug files fileIOStart := time.Now() saveThresholdedFrame(rawFrame, reading.FrameNum) saveLayoutVisualization(normalized, layout, fmt.Sprintf("review/f%d_boxes.jpg", reading.FrameNum)) timing.FileIO = time.Since(fileIOStart).Milliseconds() // Create and append review entry entry := buildReviewEntry(reading, true, failureReason, false, "") state.ReviewEntries = append(state.ReviewEntries, entry) if err := appendReviewEntry(entry); err != nil { logMessage(LogFile, Warning, " Warning: Could not append to review HTML: %v", err) } // Calculate total timing timing.Total = time.Since(frameStartTime).Milliseconds() return ProcessingResult{ Status: status, Reading: *reading, } } // processFrame runs OCR on a normalized frame with valid layout // rawFrame is the untouched frame from source (used for saving on errors) 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() logMessage(LogFile, Info, "Frame #%d", frameNum) // Apply scaling if needed scaleStart := time.Now() var normalized gocv.Mat if state.LockedScale != 1.0 { newWidth := int(float64(frame.Cols()) * state.LockedScale) newHeight := int(float64(frame.Rows()) * state.LockedScale) normalized = gocv.NewMat() gocv.Resize(frame, &normalized, image.Pt(newWidth, newHeight), 0, 0, gocv.InterpolationLinear) defer normalized.Close() logMessage(LogFile, Debug, " Applied scaling: %dx%d -> %dx%d (scale: %.3f)", frame.Cols(), frame.Rows(), newWidth, newHeight, state.LockedScale) } else { normalized = frame } timing.Scale = time.Since(scaleStart).Milliseconds() // Run OCR logMessage(LogFile, Info, " Recognizing displays...") spo2Start := time.Now() spo2Val, spo2Left, spo2LeftConf, spo2Right, spo2RightConf := recognizeDisplayArea( normalized, state.Layout.SpO2Area, p.templates, "SpO2", frameNum, p.logger) timing.OCR_SpO2 = time.Since(spo2Start).Milliseconds() hrStart := time.Now() hrVal, hrLeft, hrLeftConf, hrRight, hrRightConf := recognizeDisplayArea( normalized, state.Layout.HRArea, p.templates, "HR", frameNum, p.logger) timing.OCR_HR = time.Since(hrStart).Milliseconds() reading := Reading{ SpO2: spo2Val, SpO2LeftDigit: spo2Left, SpO2LeftConf: spo2LeftConf, SpO2RightDigit: spo2Right, SpO2RightConf: spo2RightConf, HR: hrVal, HRLeftDigit: hrLeft, HRLeftConf: hrLeftConf, HRRightDigit: hrRight, HRRightConf: hrRightConf, Timestamp: timestamp, FrameNum: frameNum, } // HANDLER #1: Check for corruption - ANY -1 digit means invalid/corrupted if reading.IsCorrupted() { validationStart := time.Now() timing.Validation += time.Since(validationStart).Milliseconds() logMessage(LogFile, Warning, " Frame #%d: Corruption detected - %s matched invalid pattern (marked as -1)", frameNum, reading.GetCorruptionDetails()) logMessage(LogFile, Info, " [CORRUPTION] Frame #%d - Digit = -1 detected: SpO2(%d,%d) HR(%d,%d) - skipping frame", frameNum, reading.SpO2LeftDigit, reading.SpO2RightDigit, reading.HRLeftDigit, reading.HRRightDigit) // Save extra debug files in DEBUG_MODE if DEBUG_MODE { debugTimestamp := time.Now().Format("20060102_150405") gocv.IMWrite(fmt.Sprintf("test_output/corruption_%s_frame%d_normalized.png", debugTimestamp, frameNum), normalized) saveLayoutVisualization(normalized, state.Layout, fmt.Sprintf("test_output/corruption_%s_frame%d_layout.jpg", debugTimestamp, frameNum)) logMessage(LogFile, Info, " 💾 Debug saved: %s", debugTimestamp) } return handleFrameFailure(rawFrame, normalized, state.Layout, &reading, StatusCorrupted, fmt.Sprintf("Corruption: %s", reading.GetCorruptionDetails()), state, timing, frameStartTime) } // HANDLER #2: Check for unrecognized digits (negative values) if reading.SpO2 < 0 || reading.HR < 0 { validationStart := time.Now() timing.Validation += time.Since(validationStart).Milliseconds() logMessage(LogFile, Info, " [UNRECOGNIZED] SpO2=%d, HR=%d - treating as low confidence", reading.SpO2, reading.HR) return handleFrameFailure(rawFrame, normalized, state.Layout, &reading, StatusLowConfidence, fmt.Sprintf("Unrecognized: SpO2=%d, HR=%d", reading.SpO2, reading.HR), state, timing, frameStartTime) } // Check if values changed validationStart := time.Now() valuesChanged := (reading.SpO2 != state.LastPosted.SpO2 || reading.HR != state.LastPosted.HR) timing.Validation += time.Since(validationStart).Milliseconds() if !valuesChanged { timing.Total = time.Since(frameStartTime).Milliseconds() logMessage(LogFile, Debug, " Frame #%d: SpO2=%d%%, HR=%d bpm (no change) - processed in %dms", frameNum, reading.SpO2, reading.HR, timing.Total) return ProcessingResult{Status: StatusNoChange, Reading: reading} } // Values changed - update state and log state.LastPosted = reading logMessage(Both, Info, "SpO2=%d%%, HR=%d bpm", reading.SpO2, reading.HR) // HANDLER #3: Check confidence validationStart = time.Now() action, _ := validateConfidence(&reading, state, p.logger) timing.Validation += time.Since(validationStart).Milliseconds() if action == ActionRetry { state.LowConfidenceCount++ logMessage(Both, Warning, " Low confidence (#%d) - grabbing next frame immediately...", state.LowConfidenceCount) spo2Avg, hrAvg := reading.AvgConfidence() return handleFrameFailure(rawFrame, normalized, state.Layout, &reading, StatusLowConfidence, fmt.Sprintf("Low confidence: SpO2 %.1f%%, HR %.1f%%", spo2Avg, hrAvg), state, timing, frameStartTime) } if action == ActionDiscard { state.LowConfidenceCount++ logMessage(Both, Warning, " Low confidence after retry (#%d)", state.LowConfidenceCount) spo2Avg, hrAvg := reading.AvgConfidence() return handleFrameFailure(rawFrame, normalized, state.Layout, &reading, StatusLowConfidence, fmt.Sprintf("Low confidence: SpO2 %.1f%%, HR %.1f%%", spo2Avg, hrAvg), state, timing, frameStartTime) } // HANDLER #4: Check stability validationStart = time.Now() action, reason := validateStability(&reading, state, p.logger) timing.Validation += time.Since(validationStart).Milliseconds() // Check for physiologically impossible values (< 40) if reading.SpO2 < 40 || reading.HR < 40 { timing.HASS = 0 logMessage(Both, Warning, " Invalid physiological values: SpO2=%d, HR=%d (below 40) - not posting to HASS", reading.SpO2, reading.HR) entry := buildReviewEntry(&reading, false, "", false, "") state.ReviewEntries = append(state.ReviewEntries, entry) if err := appendReviewEntry(entry); err != nil { logMessage(LogFile, Warning, " Warning: Could not append to review HTML: %v", err) } timing.Total = time.Since(frameStartTime).Milliseconds() return ProcessingResult{Status: StatusSuccess, Reading: reading, ShouldPost: false} } // Handle stability action if action == ActionHold { timing.HASS = 0 entry := buildReviewEntry(&reading, false, "", true, reason) state.ReviewEntries = append(state.ReviewEntries, entry) if err := appendReviewEntry(entry); err != nil { logMessage(LogFile, Warning, " Warning: Could not append to review HTML: %v", err) } logMessage(LogFile, Warning, " %s - holding for validation", reason) timing.Total = time.Since(frameStartTime).Milliseconds() return ProcessingResult{Status: StatusUnstable, Reading: reading, ShouldPost: false, UnstableReason: reason} } // SUCCESS - ActionPost or default timing.HASS = 0 // Will be updated in main loop entry := buildReviewEntry(&reading, false, "", false, "") state.ReviewEntries = append(state.ReviewEntries, entry) if err := appendReviewEntry(entry); err != nil { logMessage(LogFile, Warning, " Warning: Could not append to review HTML: %v", err) } timing.Total = time.Since(frameStartTime).Milliseconds() return ProcessingResult{Status: StatusSuccess, Reading: reading, ShouldPost: action == ActionPost} } // postReading posts a reading to Home Assistant func (p *Processor) postReading(reading *Reading, state *ProcessingState) error { // Skip posting if no config (single-frame test mode) 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++ logMessage(LogFile, Info, " ✓ Posted successfully (success: %d, fail: %d)", state.SuccessCount, state.FailureCount) return nil } state.FailureCount++ if spo2Err != nil { logMessage(LogFile, Error, " ❌ SpO2 post error: %v", spo2Err) } if hrErr != nil { logMessage(LogFile, Error, " ❌ HR post error: %v", hrErr) } logMessage(LogFile, Info, " (success: %d, fail: %d)", state.SuccessCount, state.FailureCount) logMessage(Both, Error, " ❌ Post failed") if spo2Err != nil { return spo2Err } return hrErr } // saveThresholdedFrame saves the RAW thresholded frame // Frame is ALREADY thresholded binary (from acquisition), just save it directly // Useful for debugging and can be tested with ./pulseox-monitor raw_frames/thresh_*.png func saveThresholdedFrame(frame gocv.Mat, frameNum int) { filename := fmt.Sprintf("raw_frames/thresh_%s-%05d.png", time.Now().Format("20060102"), frameNum) gocv.IMWrite(filename, frame) }