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, } } // 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() // Measure total processing time 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, } // Check for corruption (Handler #1) - ANY -1 digit means invalid/corrupted // Silently skip - invalid patterns are working as intended if reading.IsCorrupted() { // Validation check is very fast (just checking digit values) validationStart := time.Now() // Reading.IsCorrupted() was already called in the if condition timing.Validation += time.Since(validationStart).Milliseconds() // Console + Log: descriptive message with frame number 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 debug output to test_output/ with timestamp (only in debug mode) if DEBUG_MODE { debugTimestamp := time.Now().Format("20060102_150405") // Save normalized frame normalizedFilename := fmt.Sprintf("test_output/corruption_%s_frame%d_normalized.png", debugTimestamp, frameNum) gocv.IMWrite(normalizedFilename, normalized) // Save layout visualization layoutFilename := fmt.Sprintf("test_output/corruption_%s_frame%d_layout.jpg", debugTimestamp, frameNum) saveLayoutVisualization(normalized, state.Layout, layoutFilename) logMessage(LogFile, Info, " 💾 Debug saved: %s", debugTimestamp) } // Add to review HTML so we can see the digit images entry := ReviewEntry{ FrameNum: frameNum, Timestamp: 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: true, FailureReason: fmt.Sprintf("Corruption: %s", reading.GetCorruptionDetails()), } state.ReviewEntries = append(state.ReviewEntries, entry) // Append to live HTML if err := appendReviewEntry(entry); err != nil { logMessage(LogFile, Warning, " Warning: Could not append to review HTML: %v", err) } // Save thresholded raw frame (untouched, just thresholded) for replay/debugging fileIOStart := time.Now() saveThresholdedFrame(rawFrame, frameNum) // Save layout visualization for debugging saveLayoutVisualization(normalized, state.Layout, fmt.Sprintf("review/f%d_boxes.jpg", frameNum)) timing.FileIO = time.Since(fileIOStart).Milliseconds() // Calculate total timing.Total = time.Since(frameStartTime).Milliseconds() return ProcessingResult{ Status: StatusCorrupted, Reading: reading, } } // Check for negative values (unrecognized digits) - treat as low confidence if reading.SpO2 < 0 || reading.HR < 0 { validationStart := time.Now() // Validation is just the comparison above timing.Validation += time.Since(validationStart).Milliseconds() logMessage(LogFile, Info, " [UNRECOGNIZED] SpO2=%d, HR=%d - treating as low confidence", reading.SpO2, reading.HR) // Save thresholded raw frame (untouched, just thresholded) for replay/debugging fileIOStart := time.Now() saveThresholdedFrame(rawFrame, frameNum) // Save layout visualization for debugging saveLayoutVisualization(normalized, state.Layout, fmt.Sprintf("review/f%d_boxes.jpg", frameNum)) timing.FileIO = time.Since(fileIOStart).Milliseconds() // Add failed entry to review entry := ReviewEntry{ FrameNum: frameNum, Timestamp: 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: true, FailureReason: fmt.Sprintf("Unrecognized: SpO2=%d, HR=%d", reading.SpO2, reading.HR), } state.ReviewEntries = append(state.ReviewEntries, entry) // Append to live HTML if err := appendReviewEntry(entry); err != nil { logMessage(LogFile, Warning, " Warning: Could not append to review HTML: %v", err) } // Calculate total timing.Total = time.Since(frameStartTime).Milliseconds() return ProcessingResult{ Status: StatusLowConfidence, Reading: reading, } } // Check for value changes validationStart := time.Now() valuesChanged := (reading.SpO2 != state.LastPosted.SpO2 || reading.HR != state.LastPosted.HR) timing.Validation += time.Since(validationStart).Milliseconds() if !valuesChanged { // Values didn't change - no files to clean up (only created in DEBUG_MODE) // Calculate total timing.Total = time.Since(frameStartTime).Milliseconds() // Log processing time for unchanged frames 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 last posted values state.LastPosted = reading // Print to console and log (MAIN OUTPUT) // Note: timing.Total will be calculated at the end, so don't print it here logMessage(Both, Info, "SpO2=%d%%, HR=%d bpm", reading.SpO2, reading.HR) // Check confidence (Handler #2) validationStart = time.Now() action, _ := validateConfidence(&reading, state, p.logger) timing.Validation += time.Since(validationStart).Milliseconds() if action == ActionRetry { // Increment and display low confidence counter state.LowConfidenceCount++ logMessage(Both, Warning, " Low confidence (#%d) - grabbing next frame immediately...", state.LowConfidenceCount) // Save thresholded raw frame (untouched, just thresholded) for replay/debugging fileIOStart2 := time.Now() saveThresholdedFrame(rawFrame, frameNum) // Save layout visualization for debugging saveLayoutVisualization(normalized, state.Layout, fmt.Sprintf("review/f%d_boxes.jpg", frameNum)) timing.FileIO += time.Since(fileIOStart2).Milliseconds() // Add failed entry to review spo2Avg, hrAvg := reading.AvgConfidence() entry := ReviewEntry{ FrameNum: frameNum, Timestamp: 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: true, FailureReason: fmt.Sprintf("Low confidence: SpO2 %.1f%%, HR %.1f%%", spo2Avg, hrAvg), } state.ReviewEntries = append(state.ReviewEntries, entry) // Append to live HTML if err := appendReviewEntry(entry); err != nil { logMessage(LogFile, Warning, " Warning: Could not append to review HTML: %v", err) } // Calculate total timing.Total = time.Since(frameStartTime).Milliseconds() // Return status so main loop can handle escalation return ProcessingResult{ Status: StatusLowConfidence, Reading: reading, } } else if action == ActionDiscard { // Increment and display low confidence counter state.LowConfidenceCount++ logMessage(Both, Warning, " Low confidence after retry (#%d)", state.LowConfidenceCount) // Save thresholded raw frame (untouched, just thresholded) for replay/debugging fileIOStart2 := time.Now() saveThresholdedFrame(rawFrame, frameNum) // Save layout visualization for debugging saveLayoutVisualization(normalized, state.Layout, fmt.Sprintf("review/f%d_boxes.jpg", frameNum)) timing.FileIO += time.Since(fileIOStart2).Milliseconds() // Add failed entry to review spo2Avg, hrAvg := reading.AvgConfidence() entry := ReviewEntry{ FrameNum: frameNum, Timestamp: 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: true, FailureReason: fmt.Sprintf("Low confidence: SpO2 %.1f%%, HR %.1f%%", spo2Avg, hrAvg), } state.ReviewEntries = append(state.ReviewEntries, entry) // Append to live HTML if err := appendReviewEntry(entry); err != nil { logMessage(LogFile, Warning, " Warning: Could not append to review HTML: %v", err) } // Calculate total timing.Total = time.Since(frameStartTime).Milliseconds() return ProcessingResult{ Status: StatusLowConfidence, Reading: reading, } } // Check stability (Handler #3) validationStart = time.Now() action, reason := validateStability(&reading, state, p.logger) timing.Validation += time.Since(validationStart).Milliseconds() switch action { case ActionPost: // Check for physiologically impossible values (< 40) if reading.SpO2 < 40 || reading.HR < 40 { timing.HASS = 0 // No HASS posting logMessage(Both, Warning, " Invalid physiological values: SpO2=%d, HR=%d (below 40) - not posting to HASS", reading.SpO2, reading.HR) // Add to review but don't post entry := ReviewEntry{ FrameNum: frameNum, Timestamp: 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, Unstable: false, } state.ReviewEntries = append(state.ReviewEntries, entry) // Append to live HTML if err := appendReviewEntry(entry); err != nil { logMessage(LogFile, Warning, " Warning: Could not append to review HTML: %v", err) } // Calculate total timing.Total = time.Since(frameStartTime).Milliseconds() return ProcessingResult{ Status: StatusSuccess, Reading: reading, ShouldPost: false, // Don't post invalid values } } // Add to review entry := ReviewEntry{ FrameNum: frameNum, Timestamp: 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, Unstable: false, } state.ReviewEntries = append(state.ReviewEntries, entry) // Append to live HTML if err := appendReviewEntry(entry); err != nil { logMessage(LogFile, Warning, " Warning: Could not append to review HTML: %v", err) } // HASS posting happens in main loop after we return // Will be timed there timing.HASS = 0 // Placeholder, will be updated in main loop // Calculate total (before HASS post) timing.Total = time.Since(frameStartTime).Milliseconds() return ProcessingResult{ Status: StatusSuccess, Reading: reading, ShouldPost: true, } case ActionHold: timing.HASS = 0 // No HASS posting // Add to review with unstable marker entry := ReviewEntry{ FrameNum: frameNum, Timestamp: 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, Unstable: true, UnstableReason: reason, } state.ReviewEntries = append(state.ReviewEntries, entry) // Append to live HTML 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) // Calculate total timing.Total = time.Since(frameStartTime).Milliseconds() return ProcessingResult{ Status: StatusUnstable, Reading: reading, ShouldPost: false, UnstableReason: reason, } default: timing.HASS = 0 // No HASS posting timing.Total = time.Since(frameStartTime).Milliseconds() return ProcessingResult{ Status: StatusSuccess, Reading: reading, } } } // 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) { // Frame is already thresholded binary - just save it filename := fmt.Sprintf("raw_frames/thresh_%s-%05d.png", time.Now().Format("20060102"), frameNum) gocv.IMWrite(filename, frame) }